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();
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user