Files
oh-my-pi/packages/tui/test/input.test.ts
T
Leo Kim 1c264fc428 fix(tui): NFC-normalize bracketed-paste input (cursor displacement after macOS Finder drag-drop of Korean filenames)
User-visible symptom: drag-dropping a file with Korean characters in its name (e.g., `/Users/leo/Downloads/화면 기록 2026-05-13 오후 8.55.24.mov`) into the OMP input prompt leaves the hardware cursor floating ~5–8 cells past the visible filename. Static — typing immediately after places the new character at the cursor (gap), not at the visible end of the text.

Root cause (verified by direct measurement on the user's pasted path):

- macOS Finder drag-drops file paths with Korean characters in NFD form (Conjoining Jamo, U+1100..U+11FF), not NFC (Hangul Syllables, U+AC00..U+D7A3).

- Korean syllable `화` is U+D654 in NFC: 1 char, Bun.stringWidth=2 cells.

- Same syllable in NFD is U+1112 (ᄒ) + U+116A (ᅪ): 2 chars, Bun.stringWidth=3 cells (each conjoining jamo is classified Wide=2 or Narrow=1 separately).

- Terminal emulators correctly combine the NFD sequence into a single visible syllable (2 cells) per Unicode rendering rules.

- Bun's count overcounts by 1–2 cells per syllable. For a path with 6 Korean syllables: ~9 cells overcount → cursor drifts 9 cells past the filename.

- This is the same JS/Rust/terminal width-mismatch class as the earlier Compatibility Jamo issue (U+3131..U+318E from IME composition), but in a different Unicode range — and triggered by macOS Finder, not the IME.

Fix: `Input.#handlePaste` now appends `.normalize("NFC")` to the paste-text cleaning chain. The pasted bytes are converted from NFD → NFC before being inserted into `#value`, so all downstream width arithmetic (cursor positioning, viewport scrolling, padding) sees the same canonical encoding the terminal renders.

Localized to paste-time only — does not change `visibleWidthRaw` semantics, doesn't add per-render overhead, and doesn't touch the Rust width tables. Cost: ~0.5 μs per paste (single `String.normalize("NFC")` call on the pasted content).

Tests: 2 new cases in `packages/tui/test/input.test.ts` covering (a) NFD-encoded path stored as NFC after paste, (b) cursor column matches `visibleWidth(value) + prompt-width` after pasting a Korean filename. Full tui suite: 8 pre-existing failures (overlay-scroll 4 + render-regressions 4) unchanged.
2026-05-25 12:11:15 +02:00

219 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "bun:test";
import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
import { Input } from "@oh-my-pi/pi-tui/components/input";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { getIndentation } from "@oh-my-pi/pi-utils";
function renderedWidth(input: Input, width: number): number {
const [line] = input.render(width);
// TUI strips this marker before its width verification; tests should mimic that.
return visibleWidth(line.replaceAll(CURSOR_MARKER, ""));
}
describe("Input component", () => {
const wordLeft = "\x1bb"; // ESC-b (alt+b)
const wordRight = "\x1bf"; // ESC-f (alt+f)
function setupAtEnd(text: string): Input {
const input = new Input();
input.focused = true;
input.setValue(text);
input.handleInput("\x05"); // Ctrl+E (end)
return input;
}
it("moves by CJK and punctuation blocks (backward)", () => {
const text = "天气不错,去散步吧!";
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错,去散步吧|!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错,|去散步吧!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错|,去散步吧!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("|天气不错,去散步吧!");
}
});
it("moves by CJK and punctuation blocks (forward)", () => {
const text = "天气不错,去散步吧!";
const input = new Input();
input.focused = true;
input.setValue(text);
input.handleInput("\x01"); // Ctrl+A (start)
input.handleInput(wordRight);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错|,去散步吧!");
});
it("treats NBSP as whitespace for word navigation", () => {
const nbsp = "\u00A0";
const text = `Hola${nbsp}mundo`;
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe(`Hola${nbsp}|mundo`);
});
it("keeps common joiners inside words", () => {
{
const text = "co-operate l’été";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("co-operate |l’été");
}
{
const text = "co-operate l’été";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("|co-operate l’été");
}
});
it("recognizes Unicode punctuation as delimiter blocks", () => {
{
const text = "¿Cómo estás? ¡Muy bien!";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("¿Cómo estás? ¡Muy bien|!");
}
{
const text = "¿Cómo estás? ¡Muy bien!";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("¿Cómo estás? ¡Muy |bien!");
}
});
it("does not delete twice when Kitty sends backspace press and release", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("ab");
input.handleInput("\x1b[127u");
expect(input.getValue()).toBe("a");
input.handleInput("\x1b[127;1:3u");
expect(input.getValue()).toBe("a");
setKittyProtocolActive(false);
});
it("inserts NumLock keypad digits from Kitty CSI-u input", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("a");
input.handleInput("\x1b[57407;129u");
expect(input.getValue()).toBe("a8");
setKittyProtocolActive(false);
});
it("inserts keypad operators from Kitty CSI-u input", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("a");
input.handleInput("\x1b[57410u");
expect(input.getValue()).toBe("a/");
setKittyProtocolActive(false);
});
it("normalizes tabs in buffered bracketed paste using configured indentation", () => {
const input = setupAtEnd("");
input.handleInput("\x1b[200~a\t");
expect(input.getValue()).toBe("");
input.handleInput("b\r\n");
expect(input.getValue()).toBe("");
input.handleInput("c\x1b[201~");
expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`);
});
it("never renders a line wider than the terminal width (wide chars)", () => {
const input = new Input();
input.focused = true;
// Long wide-script text: string length != terminal cell width.
input.setValue("天气不错,去散步吧!".repeat(50));
input.handleInput("\x05"); // Ctrl+E (end)
const width = 40;
expect(renderedWidth(input, width)).toBeLessThanOrEqual(width);
});
it("normalizes NFD Korean pastes (macOS Finder drag-drop) to NFC", () => {
// macOS Finder drag-drops file paths in NFD (decomposed Unicode).
// Korean syllable `화` is U+D654 (1 char, 2 cells) in NFC, but
// ᄒ(U+1112) + ᅪ(U+116A) (2 chars, 3 cells per Bun.stringWidth) in NFD.
// Without normalization, the cursor lands `(NFD cells - NFC cells)`
// past the visible filename — the documented "cursor displacement"
// bug after drag-dropping a Korean filename.
const input = new Input();
input.focused = true;
const nfcPath = "/Users/leo/Downloads/화면.mov";
const nfdPath = nfcPath.normalize("NFD");
// Sanity: ensure our test fixture really differs between NFC and NFD.
expect(nfdPath).not.toBe(nfcPath);
expect(nfdPath.length).toBeGreaterThan(nfcPath.length);
// Simulate macOS bracketed-paste drop of an NFD path.
input.handleInput(`\x1b[200~${nfdPath}\x1b[201~`);
// Stored value must be NFC — no more NFD characters in the buffer.
expect(input.getValue()).toBe(nfcPath);
});
it("NFC paste: cursor column matches visible cells (no displacement)", () => {
// Regression guard for the "cursor floats past the filename" bug.
// After paste, the cursor must be at a column == visibleWidth(value)
// (plus 2 for the "> " prompt prefix).
const input = new Input();
input.focused = true;
const nfdPath = "/Users/leo/화면\\ 기록.mov".normalize("NFD");
input.handleInput(`\x1b[200~${nfdPath}\x1b[201~`);
const [line] = input.render(120);
const markerIdx = line.indexOf(CURSOR_MARKER);
expect(markerIdx).toBeGreaterThanOrEqual(0);
const col = visibleWidth(line.slice(0, markerIdx));
// Prompt "> " (2 cells) + value width in NFC (matches terminal rendering).
const expectedCol = 2 + visibleWidth(input.getValue());
expect(col).toBe(expectedCol);
});
});