From f2397bfa003678241bf072e324028ce77ac9abcd Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 16:26:13 +0800 Subject: [PATCH] fix(runtime): kitty key-release delivers input like Ink, not '' (parity) (#91) useInput had an undocumented guard that blanked `input` to '' on any kitty key-RELEASE event, so a printable release (and a ctrl+letter release) delivered nothing. Ink has no release special-case -- it classifies a kitty event purely by isPrintable/ctrl+letter, so a printable release delivers `text ?? name` and a ctrl+letter release delivers the letter name (use-input.ts:204-217). Removes the guard to match. The kept "Ctrl+C exits under kitty" divergence is unaffected: the exit check in emitInput is already scoped to `eventType !== "release"`, so a Ctrl+C release flows through as input='c' without spuriously exiting (press still exits). Co-authored-by: Claude Opus 4.8 (1M context) --- .../composables/use-input.test.tsx | 64 +++++++++++++++++++ .../pty/fixtures/use-input-kitty.tsx | 11 ++-- .../integration/pty/input-kitty.test.ts | 8 ++- packages/runtime/src/composables/useInput.ts | 11 +++- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/runtime-tests/integration/composables/use-input.test.tsx b/packages/runtime-tests/integration/composables/use-input.test.tsx index 3db7aa2..6627958 100644 --- a/packages/runtime-tests/integration/composables/use-input.test.tsx +++ b/packages/runtime-tests/integration/composables/use-input.test.tsx @@ -378,3 +378,67 @@ test("exitOnCtrlC intercepts \\x03 in raw mode", async () => { expect(handler).not.toHaveBeenCalled(); await expect(waitUntilExit()).resolves.toBeUndefined(); }); + +// --- Kitty release events deliver the key, matching Ink (no release special-case) --- +// Ink (use-input.ts:204-217) classifies a kitty event purely by isPrintable / +// ctrl+letter, regardless of press/repeat/release. A printable release ('a' up, +// CSI 97;1:3 u) therefore delivers input "a", not "". vue-tui previously had an +// undocumented `eventType === "release"` guard that blanked input to ""; these +// tests lock the Ink-matching behavior. + +test("useInput - kitty printable RELEASE delivers the key (input='a'), not ''", async () => { + const calls: Array<{ input: string; key: Key }> = []; + const App = defineComponent(() => { + useInput((input, key) => calls.push({ input, key })); + return () => listening; + }); + + const { stdin } = await render(App); + // 'a' release event (codepoint 97, modifier 1, eventType 3 = release) + await stdin.write("\x1b[97;1:3u"); + expect(calls[0]?.input).toBe("a"); + expect(calls[0]?.key.eventType).toBe("release"); +}); + +test("useInput - kitty ctrl+letter RELEASE delivers the letter name (input='a'), not ''", async () => { + const calls: Array<{ input: string; key: Key }> = []; + const App = defineComponent(() => { + useInput((input, key) => calls.push({ input, key })); + return () => listening; + }); + + const { stdin } = await render(App); + // Ctrl+A via codepoint 1-26 form (codepoint 1), modifier 5 (ctrl), + // eventType 3 (release). Not printable, but the ctrl+letter branch must still + // flow the name "a" through on release — same as Ink. + await stdin.write("\x1b[1;5:3u"); + expect(calls[0]?.input).toBe("a"); + expect(calls[0]?.key.ctrl).toBe(true); + expect(calls[0]?.key.eventType).toBe("release"); +}); + +// VERIFY no spurious exit from a Ctrl+C RELEASE under exitOnCtrlC. The kitty +// exit guard lives in emitInput (render.ts) and is scoped to `eventType !== +// "release"`, so removing the useInput release guard must NOT make a release +// exit. A Ctrl+C release should instead be delivered to the handler (input "c"). +test("useInput - kitty Ctrl+C RELEASE does not exit (delivered to handler); press still exits", async () => { + const calls: Array<{ input: string; key: Key }> = []; + const App = defineComponent(() => { + useInput((input, key) => calls.push({ input, key })); + return () => running; + }); + const { stdin, waitUntilExit } = await render(App, { exitOnCtrlC: true }); + + // Ctrl+C RELEASE (codepoint 99 'c', modifier 5 = ctrl, eventType 3 = release): + // must NOT exit; flows to the handler with input "c". + await stdin.write("\x1b[99;5:3u"); + expect(calls[0]?.input).toBe("c"); + expect(calls[0]?.key.ctrl).toBe(true); + expect(calls[0]?.key.eventType).toBe("release"); + + // Ctrl+C PRESS still exits (unchanged divergence behavior). + await stdin.write("\x1b[99;5:1u"); + await expect(waitUntilExit()).resolves.toBeUndefined(); + // Press was intercepted in emitInput and never reached the handler. + expect(calls.length).toBe(1); +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx b/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx index 9ee9b4e..8610bfc 100644 --- a/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx +++ b/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx @@ -58,7 +58,11 @@ const KittyInput = defineComponent({ return; } - if (props.test === "release" && key.eventType === "release") { + // Ink (use-input.ts:204-217) has no release special-case: a printable + // 'a' release ('a' up, CSI 97;1:3 u) delivers input "a", not "". + // Asserting input === "a" here (not just eventType) is what guards against + // the old undocumented divergence that blanked input on release. + if (props.test === "release" && input === "a" && key.eventType === "release") { exit(); return; } @@ -108,11 +112,6 @@ const KittyInput = defineComponent({ return; } - if (props.test === "releaseEmpty" && input === "" && key.eventType === "release") { - exit(); - return; - } - if (props.test === "queryResponse") { throw new Error("Query response should not reach handler"); } diff --git a/packages/runtime-tests/integration/pty/input-kitty.test.ts b/packages/runtime-tests/integration/pty/input-kitty.test.ts index 43031e3..cb06ab8 100644 --- a/packages/runtime-tests/integration/pty/input-kitty.test.ts +++ b/packages/runtime-tests/integration/pty/input-kitty.test.ts @@ -66,8 +66,12 @@ it("useInput - handle kitty protocol repeat event", async () => { expect(ps.output).toContain("exited"); }); -it("useInput - release event produces empty input", async () => { - const ps = term("use-input-kitty", ["releaseEmpty"]); +// Ink (use-input.ts:204-217) has no release special-case: a printable 'a' +// release delivers input "a" (the text), not "". The fixture exits only if +// input === "a", so a regression to the old release->"" behavior would hang +// (and fail). See .agents/docs/ink-divergences.md. +it("useInput - release event delivers the key (input='a'), matching Ink", async () => { + const ps = term("use-input-kitty", ["release"]); ps.write(kittyKey(97, 1, 3)); await ps.waitForExit(); expect(ps.output).toContain("exited"); diff --git a/packages/runtime/src/composables/useInput.ts b/packages/runtime/src/composables/useInput.ts index be3afad..cf3f044 100644 --- a/packages/runtime/src/composables/useInput.ts +++ b/packages/runtime/src/composables/useInput.ts @@ -70,9 +70,14 @@ export function useInput( let input: string; if (keypress.isKittyProtocol) { - if (keypress.eventType === "release") { - input = ""; - } else if (keypress.isPrintable) { + // No release special-case: Ink (use-input.ts:204-217) classifies a kitty + // event purely by isPrintable / ctrl+letter, regardless of + // press/repeat/release. A printable release delivers `text ?? name` (e.g. + // 'a'); a ctrl+letter release delivers the letter name. Suppressing input + // on release here was an undocumented divergence — removed for byte-parity + // with Ink. (Ctrl+C exit is scoped to non-release in emitInput, so a + // Ctrl+C release flowing through here does not trigger a spurious exit.) + if (keypress.isPrintable) { input = keypress.text ?? keypress.name; } else if (keypress.ctrl && keypress.name.length === 1) { input = keypress.name;