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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 16:26:13 +08:00
committed by GitHub
parent 860980de8a
commit f2397bfa00
4 changed files with 83 additions and 11 deletions
@@ -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 () => <Text>listening</Text>;
});
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 () => <Text>listening</Text>;
});
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 () => <Text>running</Text>;
});
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);
});
@@ -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");
}
@@ -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");
+8 -3
View File
@@ -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;