Files
oh-my-pi/packages/tui/test/input.test.ts
T
roboomp 336b993126 fix(tui): route enhanced-paste text to the focused Input prompt
On kitty (Linux/Wayland) and any terminal supporting OSC 5522 enhanced
paste, the /login API-key prompt for OpenCode Zen — and any other modal
Input prompt (Perplexity OTP, GitHub Enterprise URL, manual OAuth
redirect URL, …) — silently dropped pasted content. The user could not
paste their API key; on Enter or Esc the key surfaced in the main
prompt.

Cause: InputController.#setupEnhancedPaste enables kitty's enhanced
clipboard protocol on TUI start and consumes the resulting OSC 5522
packets in an addInputListener that runs *before* focus dispatch in
tui.#handleInput. The controller's pasteText callback then routed
unconditionally to this.ctx.editor.pasteText(text) — the main
CustomEditor — even when selector-controller had cleared editorContainer
and focused a temporary Input. The text accumulated in the detached
editor and only resurfaced when the modal was dismissed.

Fix:
- TUI.getFocused() exposes the currently focused component.
- Input.pasteText(text) mirrors Editor.pasteText so any Input can absorb
  a payload from a non-bracketed transport.
- InputController's enhanced-paste callback consults getFocused() and
  routes text to the focused component when it exposes pasteText,
  falling back to the editor only when no modal target is in focus.
- Image pastes refuse with a status message when a modal Input is
  focused, instead of stuffing a binary blob into the hidden editor.

Regression tests in packages/tui/test/input.test.ts and
packages/coding-agent/test/issue-2127-repro.test.ts pin both the new
TUI/Input contract and the InputController routing source.

Fixes #2127
2026-06-08 20:34:50 +00:00

242 lines
7.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);
});
it("terminal cursor mode emits marker without inverse-video software cursor", () => {
const input = new Input();
input.focused = true;
input.setUseTerminalCursor(true);
input.setValue("abc");
input.handleInput("\x01"); // Ctrl+A (start)
const [line] = input.render(20);
expect(line).toContain(CURSOR_MARKER);
expect(line).not.toContain("\x1b[7m");
expect(line.replaceAll(CURSOR_MARKER, "")).toContain("abc");
expect(input.getUseTerminalCursor()).toBe(true);
});
it("pasteText absorbs a payload from a non-bracketed transport (kitty OSC 5522)", () => {
// Regression for #2127: when kitty's enhanced clipboard read delivers the
// API key directly via `pasteText`, the modal Input must capture it just
// like a bracketed paste — newlines stripped, value inserted, cursor at end.
const input = setupAtEnd("");
input.pasteText("sk-line1\nsk-line2\r\nsk-line3");
expect(input.getValue()).toBe("sk-line1sk-line2sk-line3");
});
});