From a2ae92c9c2a354716a0439b9302fc398e627b221 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 8 Feb 2026 16:11:44 +0100 Subject: [PATCH 1/2] test(stdin-buffer): add tests for double-escape sequence handling Regression tests for #644 where Option+Arrow on macOS (e.g. \x1b\x1b[D) was incorrectly split into meta+escape and literal characters. --- packages/core/src/lib/stdin-buffer.test.ts | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/src/lib/stdin-buffer.test.ts b/packages/core/src/lib/stdin-buffer.test.ts index f05ae357a..2215a4c61 100644 --- a/packages/core/src/lib/stdin-buffer.test.ts +++ b/packages/core/src/lib/stdin-buffer.test.ts @@ -288,6 +288,55 @@ describe("StdinBuffer", () => { expect(emittedSequences).toEqual(["h", "\x1b[<35;10;5m", "e", "l"]) }) + + // Regression: https://github.com/anomalyco/opentui/issues/644 + // Option+Arrow on macOS sends double-escape sequences like \x1b\x1b[D. + // The stdin buffer was incorrectly splitting these into \x1b\x1b (meta+escape) + // and literal "[D" characters, instead of keeping the whole sequence together. + describe("Double-escape sequences (Option+Arrow on macOS)", () => { + it("should keep Option+Left (\\x1b\\x1b[D) as a single sequence", () => { + processInput("\x1b\x1b[D") + expect(emittedSequences).toEqual(["\x1b\x1b[D"]) + }) + + it("should keep Option+Right (\\x1b\\x1b[C) as a single sequence", () => { + processInput("\x1b\x1b[C") + expect(emittedSequences).toEqual(["\x1b\x1b[C"]) + }) + + it("should keep Option+Up (\\x1b\\x1b[A) as a single sequence", () => { + processInput("\x1b\x1b[A") + expect(emittedSequences).toEqual(["\x1b\x1b[A"]) + }) + + it("should keep Option+Down (\\x1b\\x1b[B) as a single sequence", () => { + processInput("\x1b\x1b[B") + expect(emittedSequences).toEqual(["\x1b\x1b[B"]) + }) + + it("should handle Option+Arrow arriving in chunks", () => { + processInput("\x1b") + processInput("\x1b[D") + expect(emittedSequences).toEqual(["\x1b\x1b[D"]) + }) + + it("should handle Option+Arrow with modifier parameters", () => { + // e.g. Option+Shift+Right: ESC ESC [1;2C + processInput("\x1b\x1b[1;2C") + expect(emittedSequences).toEqual(["\x1b\x1b[1;2C"]) + }) + + it("should handle double-escape with SS3 sequence", () => { + // ESC ESC O A (meta + SS3 Up) + processInput("\x1b\x1bOA") + expect(emittedSequences).toEqual(["\x1b\x1bOA"]) + }) + + it("should handle Option+Arrow mixed with regular input", () => { + processInput("a\x1b\x1b[Db") + expect(emittedSequences).toEqual(["a", "\x1b\x1b[D", "b"]) + }) + }) }) describe("Bracketed Paste", () => { From eb1fc020a61f245158be5363241fc951c4cce7b8 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 8 Feb 2026 16:31:48 +0100 Subject: [PATCH 2/2] fix(stdin-buffer): handle double-escape sequences in input parsing Recognize ESC ESC prefix sequences (e.g. Option+Arrow keys in iTerm2) by recursively checking completeness after stripping the leading ESC. When buffering incomplete input, emit a bare ESC ESC as its own sequence unless followed by a valid nested escape introducer like `[`, `O`, etc. --- packages/core/src/lib/stdin-buffer.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/core/src/lib/stdin-buffer.ts b/packages/core/src/lib/stdin-buffer.ts index 8926c76ab..7584d1ced 100644 --- a/packages/core/src/lib/stdin-buffer.ts +++ b/packages/core/src/lib/stdin-buffer.ts @@ -34,6 +34,11 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca const afterEsc = data.slice(1) + // Double-escape sequences: ESC ESC [ ... / ESC ESC O ... + if (afterEsc.startsWith(ESC)) { + return isCompleteSequence(afterEsc) + } + // CSI sequences: ESC [ if (afterEsc.startsWith("[")) { // Check for old-style mouse sequence: ESC[M + 3 bytes @@ -175,6 +180,13 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" { return "incomplete" } +/* Check if a character can start a nested escape sequence after a double ESC + * For example, ESC ESC [ ... for Option+Arrow keys in iTerm2. + */ +function isNestedEscapeSequenceStart(char: string | undefined): boolean { + return char === "[" || char === "]" || char === "O" || char === "N" || char === "P" || char === "_" +} + /** * Split accumulated buffer into complete sequences */ @@ -198,6 +210,17 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain pos += seqEnd break } else if (status === "incomplete") { + // Keep ESC ESC as its own sequence unless it is followed by a nested + // escape introducer (e.g. ESC ESC [D for Option+Left). + if (candidate === ESC + ESC) { + const nextChar = remaining[seqEnd] + if (seqEnd < remaining.length && !isNestedEscapeSequenceStart(nextChar)) { + sequences.push(candidate) + pos += seqEnd + break + } + } + seqEnd++ } else { // Should not happen when starting with ESC