1c264fc428
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.
219 lines
6.8 KiB
TypeScript
219 lines
6.8 KiB
TypeScript
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);
|
||
});
|
||
});
|