diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index c7b93de..b90e2d9 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -64,6 +64,23 @@ deliberate. Divergences fall into a few kinds: Maintainer decision (2026-05-30): KEEP. Tests: `usePaste-only app exits on {legacy,kitty} Ctrl+C` in `input-kitty.test.ts`. +### `parseKeypress` filters kitty query-responses (second safety net) + +- **Ink:** filters kitty keyboard-protocol query-responses (`ESC[?Nu`) in exactly **one** place — + the auto-detection lifecycle in `ink.tsx` (`stripKittyQueryResponsesAndTrailingPartial` on a + private `onData` buffer). Its `parse-keypress.ts` has no query-response branch. +- **vue-tui:** mirrors that detection layer (in `kitty-keyboard.ts`) **and** adds a second net — + `parseKeypress` returns `{ ignore: true }` for `ESC[?Nu`, which `useInput` then drops. +- **Why:** the detection layer does **not** cover the real input pipeline (`stdin 'data'` → + `inputParser` → `emitInput` → `useInput` → `parseKeypress`). In `enabled` mode it never runs; + in `auto` mode its `onData` listener and the stdin controller's `handleData` both subscribe to + the same `'data'` event, so stripping its private buffer can't stop the chunk reaching + `handleData`; and after detection settles the listener is gone. Empirically (Layer 2 removed, + rebuilt) a stray query-response reaches a `useInput` handler as spurious `"[?1u"` input in all + of those cases — including a response split across two reads, which `inputParser` reassembles + before dispatch. So this is load-bearing, not redundant. Introduced 2026-05-31. Tests: "kitty + query-response - end-to-end filtering" in `kitty-lifecycle.test.ts` (RED without it). + ### Non-`Error` thrown values keep their message in the error overview - **Ink:** `ErrorOverview` renders `error.message`; a thrown non-`Error` (`throw 'boom'`) has no diff --git a/packages/runtime-tests/integration/kitty-lifecycle.test.ts b/packages/runtime-tests/integration/kitty-lifecycle.test.ts index 8e652f3..28548ad 100644 --- a/packages/runtime-tests/integration/kitty-lifecycle.test.ts +++ b/packages/runtime-tests/integration/kitty-lifecycle.test.ts @@ -7,8 +7,8 @@ import { stripKittyQueryResponsesAndTrailingPartial, resolveFlags, } from "@vue-tui/runtime/internal"; -import { createApp } from "@vue-tui/runtime"; -import { defineComponent } from "vue"; +import { createApp, useInput } from "@vue-tui/runtime"; +import { defineComponent, h } from "vue"; const textEncoder = new TextEncoder(); @@ -424,3 +424,82 @@ describe("kitty lifecycle - mount/unmount integration", () => { app.unmount(); }); }); + +// --- Query-response must never reach a useInput handler (Layer 2 regression) --- +// +// vue-tui filters kitty query-responses (ESC[?Nu) in TWO places: +// Layer 1 — the one-shot auto-detection onData listener in kitty-keyboard.ts +// (a faithful port of Ink's ink.tsx detection): strips responses +// from ITS OWN private buffer and unshifts the non-query remainder. +// Layer 2 — parseKeypress returns {ignore:true} for ESC[?Nu; useInput drops it. +// +// Layer 1 alone does NOT cover the real input pipeline +// (stdin 'data' → inputParser → emitInput → useInput → parseKeypress): +// * In `enabled` mode no detection listener exists at all. +// * In `auto` mode the detection onData and the controller's handleData are +// both subscribed to the SAME 'data' event, so stripping Layer 1's private +// buffer doesn't stop the chunk reaching handleData. +// * After detection settles, the detection listener is gone entirely. +// In every case the query-response flows to parseKeypress, so Layer 2 is +// load-bearing — Ink lacks it (a documented additive divergence). These tests +// lock that: with Layer 2 removed they fail (handler sees a spurious "[?1u"). +function mountWithInput(kittyKeyboard: { mode: "auto" | "enabled" }) { + const { stdout } = createFakeStdout(); + const { stdin } = createFakeStdin(); + (stdin as any).read = vi.fn(() => null); + (stdin as any).ref = vi.fn(); + (stdin as any).unref = vi.fn(); + + const inputs: string[] = []; + const App = defineComponent(() => { + useInput((input) => { + inputs.push(input); + }); + return () => h("text", null, "x"); + }); + + const app = createApp(App); + app.mount({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + kittyKeyboard, + }); + return { app, stdin, inputs }; +} + +describe("kitty query-response - end-to-end filtering", () => { + test("enabled mode: stray query-response never reaches useInput", () => { + // No auto-detection runs in `enabled` mode, so Layer 1 is absent here. + const { app, stdin, inputs } = mountWithInput({ mode: "enabled" }); + stdin.emit("data", "\x1b[?1u"); + expect(inputs).toEqual([]); + app.unmount(); + }); + + test("auto mode: query-response during detection never reaches useInput", () => { + // Detection onData and the controller's handleData both see this chunk. + const { app, stdin, inputs } = mountWithInput({ mode: "auto" }); + stdin.emit("data", "\x1b[?1u"); + expect(inputs).toEqual([]); + app.unmount(); + }); + + test("auto mode: query-response after detection settled never reaches useInput", () => { + const { app, stdin, inputs } = mountWithInput({ mode: "auto" }); + stdin.emit("data", "\x1b[?1u"); // settles detection; removes Layer 1 listener + inputs.length = 0; + stdin.emit("data", "\x1b[?1u"); // stray, late response + expect(inputs).toEqual([]); + app.unmount(); + }); + + test("enabled mode: query-response split across two chunks never reaches useInput", () => { + // inputParser reassembles "\x1b[?" + "1u" into a full CSI sequence before + // dispatch, so Layer 2 (not Layer 1's trailing-partial logic) is what filters it. + const { app, stdin, inputs } = mountWithInput({ mode: "enabled" }); + stdin.emit("data", "\x1b[?"); + stdin.emit("data", "1u"); + expect(inputs).toEqual([]); + app.unmount(); + }); +}); diff --git a/packages/runtime/src/io/parse-keypress.ts b/packages/runtime/src/io/parse-keypress.ts index 7700a53..25cd67d 100644 --- a/packages/runtime/src/io/parse-keypress.ts +++ b/packages/runtime/src/io/parse-keypress.ts @@ -9,6 +9,15 @@ const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/; const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; +// Kitty keyboard-protocol query-response (ESC[?Nu). Ink has NO equivalent here: +// it filters these only in its detection lifecycle (ink.tsx), the layer vue-tui +// mirrors in kitty-keyboard.ts. But that detection layer does NOT cover the real +// input pipeline — in `enabled` mode it never runs, and in `auto` mode its +// onData listener and the stdin controller's handleData both subscribe to the +// same 'data' event, so it can't stop a query-response reaching parseKeypress. +// This regex is the load-bearing second net that keeps a stray query-response +// from surfacing as spurious input. See .agents/docs/ink-divergences.md and the +// "kitty query-response - end-to-end filtering" tests for the proof. const kittyQueryResponseRe = /^\x1b\[\?\d+u$/; const keyName: Record = {