Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/core/src/lib/stdin-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/lib/stdin-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand Down
Loading