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:
@@ -378,3 +378,67 @@ test("exitOnCtrlC intercepts \\x03 in raw mode", async () => {
|
|||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
await expect(waitUntilExit()).resolves.toBeUndefined();
|
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;
|
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();
|
exit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,11 +112,6 @@ const KittyInput = defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.test === "releaseEmpty" && input === "" && key.eventType === "release") {
|
|
||||||
exit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.test === "queryResponse") {
|
if (props.test === "queryResponse") {
|
||||||
throw new Error("Query response should not reach handler");
|
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");
|
expect(ps.output).toContain("exited");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("useInput - release event produces empty input", async () => {
|
// Ink (use-input.ts:204-217) has no release special-case: a printable 'a'
|
||||||
const ps = term("use-input-kitty", ["releaseEmpty"]);
|
// 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));
|
ps.write(kittyKey(97, 1, 3));
|
||||||
await ps.waitForExit();
|
await ps.waitForExit();
|
||||||
expect(ps.output).toContain("exited");
|
expect(ps.output).toContain("exited");
|
||||||
|
|||||||
@@ -70,9 +70,14 @@ export function useInput(
|
|||||||
|
|
||||||
let input: string;
|
let input: string;
|
||||||
if (keypress.isKittyProtocol) {
|
if (keypress.isKittyProtocol) {
|
||||||
if (keypress.eventType === "release") {
|
// No release special-case: Ink (use-input.ts:204-217) classifies a kitty
|
||||||
input = "";
|
// event purely by isPrintable / ctrl+letter, regardless of
|
||||||
} else if (keypress.isPrintable) {
|
// 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;
|
input = keypress.text ?? keypress.name;
|
||||||
} else if (keypress.ctrl && keypress.name.length === 1) {
|
} else if (keypress.ctrl && keypress.name.length === 1) {
|
||||||
input = keypress.name;
|
input = keypress.name;
|
||||||
|
|||||||
Reference in New Issue
Block a user