From 3f380d0e95b4b9d3a596600c08c3ee50aca0e181 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 5 Feb 2026 18:52:36 -0800 Subject: [PATCH] fix: filter keyboard protocol reports and scope Linux Enter handling (#844) Co-authored-by: Letta --- src/tests/use-input-key-sequences.test.ts | 86 ++++++++++++++++++++++ vendor/ink/build/hooks/use-input.js | 89 ++++++++++++++++++++--- 2 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 src/tests/use-input-key-sequences.test.ts diff --git a/src/tests/use-input-key-sequences.test.ts b/src/tests/use-input-key-sequences.test.ts new file mode 100644 index 0000000..32fd1bb --- /dev/null +++ b/src/tests/use-input-key-sequences.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +const useInputModuleUrl = new URL( + "../../node_modules/ink/build/hooks/use-input.js", + import.meta.url, +).href; + +async function loadTestUtils() { + const mod = await import(useInputModuleUrl); + return mod.__lettaUseInputTestUtils as { + isProtocolReportSequence: (data: unknown) => boolean; + stripTrailingNewlineFromCsiU: (data: unknown) => unknown; + shouldSuppressBareEnterAfterModifiedEnter: ( + data: unknown, + suppressBareEnter: boolean, + platform?: string, + ) => boolean; + shouldStartModifiedEnterSuppression: ( + keypress: { + name?: string; + shift?: boolean; + ctrl?: boolean; + meta?: boolean; + option?: boolean; + }, + platform?: string, + ) => boolean; + shouldTreatAsReturn: (keypressName: string, platform?: string) => boolean; + }; +} + +describe("use-input key sequence handling", () => { + test("filters protocol report spam sequences", async () => { + const t = await loadTestUtils(); + + expect(t.isProtocolReportSequence("\x1b[?1u")).toBe(true); + expect( + t.isProtocolReportSequence("\x1b[?0u\x1b[?64;1;2;4;6;17;18;21;22;52c"), + ).toBe(true); + expect(t.isProtocolReportSequence("\x1b[13;2u")).toBe(false); + expect(t.isProtocolReportSequence("a")).toBe(false); + }); + + test("strips only trailing newline from CSI-u Enter payload", async () => { + const t = await loadTestUtils(); + + expect(t.stripTrailingNewlineFromCsiU("\x1b[13;2u\n")).toBe("\x1b[13;2u"); + expect(t.stripTrailingNewlineFromCsiU("\x1b[13;2:1u\r\n")).toBe( + "\x1b[13;2:1u", + ); + expect(t.stripTrailingNewlineFromCsiU("\x1b[13;2u")).toBe("\x1b[13;2u"); + }); + + test("maps Enter-as-submit by platform correctly", async () => { + const t = await loadTestUtils(); + + expect(t.shouldTreatAsReturn("return", "linux")).toBe(true); + expect(t.shouldTreatAsReturn("enter", "linux")).toBe(true); + expect(t.shouldTreatAsReturn("return", "darwin")).toBe(true); + expect(t.shouldTreatAsReturn("enter", "darwin")).toBe(false); + }); + + test("suppresses only immediate bare enter after modified enter on linux", async () => { + const t = await loadTestUtils(); + + expect( + t.shouldStartModifiedEnterSuppression( + { name: "return", shift: true }, + "linux", + ), + ).toBe(true); + expect( + t.shouldSuppressBareEnterAfterModifiedEnter("\n", true, "linux"), + ).toBe(true); + + expect( + t.shouldStartModifiedEnterSuppression( + { name: "return", shift: true }, + "darwin", + ), + ).toBe(false); + expect( + t.shouldSuppressBareEnterAfterModifiedEnter("\n", true, "darwin"), + ).toBe(false); + }); +}); diff --git a/vendor/ink/build/hooks/use-input.js b/vendor/ink/build/hooks/use-input.js index bdfe365..8b8aabf 100644 --- a/vendor/ink/build/hooks/use-input.js +++ b/vendor/ink/build/hooks/use-input.js @@ -3,6 +3,39 @@ import parseKeypress, { nonAlphanumericKeys } from '../parse-keypress.js'; import reconciler from '../reconciler.js'; import useStdin from './use-stdin.js'; +const IS_LINUX = process.platform === 'linux'; +const CSI_U_WITH_TRAILING_NEWLINE_PATTERN = /^(\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u)(?:\r?\n)$/; +const CSI_PROTOCOL_REPORT_PATTERN = /^(?:\x1b\[\?(?:\d+;)*\d+[uc])+$/; +const BARE_ENTER_SUPPRESSION_WINDOW_MS = 75; + +const isLinuxPlatform = (platform = process.platform) => platform === 'linux'; +const isProtocolReportSequence = (data) => typeof data === 'string' && CSI_PROTOCOL_REPORT_PATTERN.test(data); +const stripTrailingNewlineFromCsiU = (data) => { + if (typeof data !== 'string') { + return data; + } + const match = data.match(CSI_U_WITH_TRAILING_NEWLINE_PATTERN); + return match ? match[1] : data; +}; +const shouldSuppressBareEnterAfterModifiedEnter = (data, suppressBareEnter, platform = process.platform) => + isLinuxPlatform(platform) && suppressBareEnter && (data === '\n' || data === '\r'); +const shouldStartModifiedEnterSuppression = (keypress, platform = process.platform) => + isLinuxPlatform(platform) && + keypress?.name === 'return' && + (keypress.shift || keypress.ctrl || keypress.meta || keypress.option); +const shouldTreatAsReturn = (keypressName, platform = process.platform) => + keypressName === 'return' || (isLinuxPlatform(platform) && keypressName === 'enter'); + +// Exported for targeted key-sequence regression tests. +export const __lettaUseInputTestUtils = { + isLinuxPlatform, + isProtocolReportSequence, + stripTrailingNewlineFromCsiU, + shouldSuppressBareEnterAfterModifiedEnter, + shouldStartModifiedEnterSuppression, + shouldTreatAsReturn, +}; + // Patched for bracketed paste: propagate "isPasted" and avoid leaking ESC sequences // Also patched to use ref for inputHandler to avoid effect churn with inline handlers const useInput = (inputHandler, options = {}) => { @@ -27,6 +60,9 @@ const useInput = (inputHandler, options = {}) => { return; } + let suppressBareEnter = false; + let suppressBareEnterUntil = 0; + const handleData = (data) => { // Handle bracketed paste events emitted by Ink stdin manager if (data && typeof data === 'object' && data.isPasted) { @@ -53,13 +89,29 @@ const useInput = (inputHandler, options = {}) => { return; } - // Normalize bare \n (Linux Enter) to \r before parsing. - // parseKeypress maps \r to name:'return' but \n to name:'enter'. - // Only 'return' should set key.return=true - treating 'enter' as - // return breaks Shift+Enter on terminals that emit a stray \n - // alongside the CSI u sequence. - if (data === '\n') { - data = '\r'; + if (typeof data === 'string') { + // Drop kitty/xterm keyboard-protocol negotiation/status reports + // (e.g. ESC[?1u, ESC[?....c). These are not user keypresses. + if (isProtocolReportSequence(data)) { + return; + } + + // Some terminals deliver modified Enter as CSI u plus a trailing + // newline byte in the same chunk. Parse only the CSI u sequence. + data = stripTrailingNewlineFromCsiU(data); + + if (IS_LINUX && suppressBareEnter && Date.now() > suppressBareEnterUntil) { + suppressBareEnter = false; + suppressBareEnterUntil = 0; + } + + // Linux-only: when modified Enter is followed by a plain newline + // event, drop that immediate newline so Shift+Enter doesn't submit. + if (shouldSuppressBareEnterAfterModifiedEnter(data, suppressBareEnter)) { + suppressBareEnter = false; + suppressBareEnterUntil = 0; + return; + } } let keypress = parseKeypress(data); @@ -94,7 +146,7 @@ const useInput = (inputHandler, options = {}) => { if (event === 3) { return; } - + // Map keycodes to names const csiUKeyMap = { 9: 'tab', @@ -102,16 +154,16 @@ const useInput = (inputHandler, options = {}) => { 27: 'escape', 127: 'backspace', }; - + let name = csiUKeyMap[keycode] || ''; - + // Handle letter keycodes (a-z: 97-122, A-Z: 65-90) if (!name && keycode >= 97 && keycode <= 122) { name = String.fromCharCode(keycode); // lowercase letter } else if (!name && keycode >= 65 && keycode <= 90) { name = String.fromCharCode(keycode + 32); // convert to lowercase } - + if (name) { keypress = { name, @@ -125,6 +177,17 @@ const useInput = (inputHandler, options = {}) => { } } } + + // Modified Enter can be followed by an extra bare newline event + // on some terminals. Suppress only that immediate follow-up. + if (shouldStartModifiedEnterSuppression(keypress)) { + suppressBareEnter = true; + suppressBareEnterUntil = Date.now() + BARE_ENTER_SUPPRESSION_WINDOW_MS; + } else if (IS_LINUX && keypress.name === 'enter' && suppressBareEnter) { + suppressBareEnter = false; + suppressBareEnterUntil = 0; + return; + } const key = { upArrow: keypress.name === 'up', @@ -133,7 +196,9 @@ const useInput = (inputHandler, options = {}) => { rightArrow: keypress.name === 'right', pageDown: keypress.name === 'pagedown', pageUp: keypress.name === 'pageup', - return: keypress.name === 'return', + // Linux terminals may emit Enter as name:"enter" (\n), while + // macOS terminals keep Enter as name:"return" (\r). + return: shouldTreatAsReturn(keypress.name), escape: keypress.name === 'escape', ctrl: keypress.ctrl, shift: keypress.shift,