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;