From 7f30fb613623867997d412d754e4301b75e5d9dd Mon Sep 17 00:00:00 2001 From: "Mikhail f. Shiryaev" Date: Wed, 13 May 2026 18:42:05 +0200 Subject: [PATCH] Address edge-case with kitty protocol in wezterm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/tui/src/stdin-buffer.ts — in extractCompleteSequences, after finding \x1b\x1b as "complete" (legacy meta-key), check if the next character in the buffer would start a new escape sequence ([, ], O, P, _). If so, emit only the first \x1b and let the second ESC begin a new parse iteration. packages/tui/test/stdin-buffer.test.ts — three new cases in the Kitty section: - \x1b\x1b[27;129:3u (num_lock) splits into ["\x1b", "\x1b[27;129:3u"] - \x1b\x1b[27;1:3u (no num_lock) splits into ["\x1b", "\x1b[27;1:3u"] - \x1b\x1b alone (no following CSI) still emits as ["\x1b\x1b"] — preserves ctrl+alt+[ --- packages/tui/src/stdin-buffer.ts | 23 +++++++++++++++++++++++ packages/tui/test/stdin-buffer.test.ts | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 4aaf4627..a8b0b847 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -205,6 +205,29 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain const status = isCompleteSequence(candidate); if (status === "complete") { + // WezTerm with enable_kitty_keyboard sends the Escape key press as a + // raw '\x1b' byte (simple text path in encode_kitty, ignoring + // DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u + // sequence. These arrive concatenated as '\x1b\x1b[27;...u'. + // The buffer would normally treat '\x1b\x1b' as a complete meta-key + // sequence (ESC + single char), leaving '[27;...u' to be typed as + // plain text. If the character immediately following '\x1b\x1b' + // would begin a new escape sequence, emit only the first ESC and + // restart from the second. + if (candidate === "\x1b\x1b") { + const nextChar = remaining[seqEnd]; + if ( + nextChar === "[" || // CSI + nextChar === "]" || // OSC + nextChar === "O" || // SS3 + nextChar === "P" || // DCS + nextChar === "_" // APC + ) { + sequences.push(ESC); + pos += 1; + break; + } + } sequences.push(candidate); pos += seqEnd; break; diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 549b15f2..4f4a0061 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -198,6 +198,26 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]); }); + it("should split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => { + // WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b + // and the release as a full Kitty CSI-u sequence, concatenated. + // The buffer must not treat \x1b\x1b as a complete meta-key when the + // following byte starts a new escape sequence. + processInput("\x1b\x1b[27;129:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]); + }); + + it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => { + processInput("\x1b\x1b[27;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]); + }); + + it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => { + // \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[ + processInput("\x1b\x1b"); + assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]); + }); + it("should handle plain characters mixed with Kitty sequences", () => { // Plain 'a' followed by Kitty release processInput("a\x1b[97;1:3u");