From ad80a339d509c35d48cfbeb7e6a09b65a4729c6a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:24:55 +1000 Subject: [PATCH 1/3] fix: don't timeout-flush unambiguous escape sequences split across reads --- packages/core/src/lib/stdin-parser.test.ts | 75 ++++++++++--- packages/core/src/lib/stdin-parser.ts | 100 +++++------------- .../core/src/tests/renderer.input.test.ts | 11 +- 3 files changed, 97 insertions(+), 89 deletions(-) diff --git a/packages/core/src/lib/stdin-parser.test.ts b/packages/core/src/lib/stdin-parser.test.ts index 4be1375ff9..5976bb5a03 100644 --- a/packages/core/src/lib/stdin-parser.test.ts +++ b/packages/core/src/lib/stdin-parser.test.ts @@ -368,13 +368,14 @@ describe("StdinParser", () => { } }) - test("SS3 timeout-flushed as unknown response", () => { + test("SS3 stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1bO")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1bO")]) + // ESC O is unambiguous — not flushed on timeout + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } @@ -906,49 +907,93 @@ describe("StdinParser", () => { } }) - test("timeout flushes partial OSC as unknown", () => { + test("partial OSC stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b]incomplete")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1b]incomplete")]) + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } }) - test("timeout flushes partial DCS as unknown", () => { + test("partial DCS stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1bPpartial")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1bPpartial")]) + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } }) - test("timeout flushes partial APC as unknown", () => { + test("partial APC stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b_partial")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1b_partial")]) + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } }) - test("timeout flushes partial CSI as unknown", () => { + test("partial CSI stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b[123")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1b[123")]) + expect(snap(parser)).toEqual([]) + } finally { + parser.destroy() + } + }) + + test("split CSI across reads reassembles after timeout", () => { + const { parser, clock } = createTimedParser() + try { + // Kitty Ctrl+V release split across two reads + parser.push(Buffer.from("\x1b[118;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + // Stays pending — not flushed + expect(snap(parser)).toEqual([]) + parser.push(Buffer.from(";3u")) + expect(snap(parser)).toEqual([k("v", { ctrl: true, raw: "\x1b[118;5;3u" })]) + } finally { + parser.destroy() + } + }) + + test("split OSC across reads reassembles after timeout", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b]52;c;")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + parser.push(Buffer.from("dGVzdA==\x07")) + expect(snap(parser)).toEqual([resp("osc", "\x1b]52;c;dGVzdA==\x07")]) + } finally { + parser.destroy() + } + }) + + test("split DCS across reads reassembles after timeout", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1bP>|kit")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + parser.push(Buffer.from("ty(0.42.2)\x1b\\")) + expect(snap(parser)).toEqual([resp("dcs", "\x1bP>|kitty(0.42.2)\x1b\\")]) } finally { parser.destroy() } @@ -1137,7 +1182,7 @@ describe("StdinParser", () => { } }) - test("after timed-out ESC, partial [< waits, then timeout flushes as one response", () => { + test("after timed-out ESC, partial [< stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b")) @@ -1147,7 +1192,8 @@ describe("StdinParser", () => { parser.push(Buffer.from("[<35;20")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "[<35;20")]) + // esc_less_mouse is unambiguous — stays pending + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } @@ -1649,13 +1695,14 @@ describe("StdinParser", () => { ["bracketed paste end outside paste mode is a CSI response", "\x1b[201~", [resp("csi", "\x1b[201~")]], ]) - test("partial X10 times out as one unknown response", () => { + test("partial X10 stays pending after timeout", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b[M !")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([resp("unknown", "\x1b[M !")]) + // X10 mouse payload is unambiguous — stays pending + expect(snap(parser)).toEqual([]) } finally { parser.destroy() } diff --git a/packages/core/src/lib/stdin-parser.ts b/packages/core/src/lib/stdin-parser.ts index 1968c6c777..bb300acd86 100644 --- a/packages/core/src/lib/stdin-parser.ts +++ b/packages/core/src/lib/stdin-parser.ts @@ -689,15 +689,10 @@ export class StdinParser { case "ss3": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // ESC O already seen — unambiguously SS3, not a lone ESC. + // Wait for the next byte rather than timeout-flushing. + this.markPending() + return } if (byte === ESC) { @@ -750,15 +745,12 @@ export class StdinParser { case "csi": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // ESC[ already seen — unambiguously CSI. Wait for final byte + // or interruption by a new ESC (handled below). Don't + // timeout-flush; split sequences across reads are common on + // Windows Terminal with kitty keyboard. + this.markPending() + return } // A new ESC inside an incomplete CSI means the previous sequence @@ -777,15 +769,9 @@ export class StdinParser { if (byte === 0x4d && this.cursor === this.unitStart + 2) { const end = this.cursor + 4 if (bytes.length < end) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, bytes.length)) - this.state = { tag: "ground" } - this.consumePrefix(bytes.length) - continue + // Inside X10 mouse payload, wait for remaining bytes. + this.markPending() + return } this.emitMouse(bytes.subarray(this.unitStart, end), "x10") @@ -854,15 +840,9 @@ export class StdinParser { // since the two-byte ESC \ can split across push() calls. case "osc": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // ESC] already seen — wait for BEL or ESC \ terminator. + this.markPending() + return } if (this.state.sawEsc) { @@ -898,15 +878,9 @@ export class StdinParser { case "dcs": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // ESC P already seen — wait for ESC \ terminator. + this.markPending() + return } if (this.state.sawEsc) { @@ -934,15 +908,9 @@ export class StdinParser { case "apc": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // ESC _ already seen — wait for ESC \ terminator. + this.markPending() + return } if (this.state.sawEsc) { @@ -973,15 +941,9 @@ export class StdinParser { // opaque response so split mouse bytes never leak into text. case "esc_less_mouse": { if (this.cursor >= bytes.length) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) - this.state = { tag: "ground" } - this.consumePrefix(this.cursor) - continue + // Inside mouse sequence, wait for final byte. + this.markPending() + return } if ((byte >= 0x30 && byte <= 0x39) || byte === 0x3b) { @@ -1010,15 +972,9 @@ export class StdinParser { const end = this.unitStart + 5 if (bytes.length < end) { - if (!this.forceFlush) { - this.markPending() - return - } - - this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, bytes.length)) - this.state = { tag: "ground" } - this.consumePrefix(bytes.length) - continue + // Inside X10 mouse payload, wait for remaining bytes. + this.markPending() + return } this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, end)) diff --git a/packages/core/src/tests/renderer.input.test.ts b/packages/core/src/tests/renderer.input.test.ts index a107858471..7e01eda199 100644 --- a/packages/core/src/tests/renderer.input.test.ts +++ b/packages/core/src/tests/renderer.input.test.ts @@ -1498,20 +1498,25 @@ test("capability response followed by keypress", async () => { expect(keypresses[0].name).toBe("a") }) -test("partial SGR mouse flushed on timeout should not trigger keypress", async () => { +test("partial SGR mouse stays pending on timeout, completes when rest arrives", async () => { const keypresses: KeyEvent[] = [] currentRenderer.keyInput.on("keypress", (event) => { keypresses.push(event) }) - // Incomplete SGR mouse sequence; the native parser flushes this token on timeout. + // Incomplete SGR mouse sequence; stays pending (not flushed on timeout). currentRenderer.stdin.emit("data", Buffer.from("\x1b[<35;20")) // Wait past native stdin parser timeout (10ms) advanceCurrentClock() expect(keypresses).toHaveLength(0) - // Ensure normal key input still works after the filtered flush + // Completing the mouse sequence should not trigger keypress either + currentRenderer.stdin.emit("data", Buffer.from(";5m")) + advanceCurrentClock() + expect(keypresses).toHaveLength(0) + + // Normal key input still works after currentRenderer.stdin.emit("data", Buffer.from("x")) advanceCurrentClock() From 03a20c031bfd1cf9107b41e065d72ee59ab5e637 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:32:36 +1000 Subject: [PATCH 2/3] fix: narrow escape timeout recovery to CSI Keep split CSI sequences resumable across timeout while flushing unsafe framed escape states. Also stop timed-out pending CSI from re-arming the parser wakeup loop until new bytes arrive. --- packages/core/src/lib/stdin-parser.test.ts | 103 ++++++++++++++---- packages/core/src/lib/stdin-parser.ts | 102 ++++++++++++----- .../core/src/tests/renderer.input.test.ts | 34 ++++++ 3 files changed, 190 insertions(+), 49 deletions(-) diff --git a/packages/core/src/lib/stdin-parser.test.ts b/packages/core/src/lib/stdin-parser.test.ts index 5976bb5a03..eaf35508c6 100644 --- a/packages/core/src/lib/stdin-parser.test.ts +++ b/packages/core/src/lib/stdin-parser.test.ts @@ -368,14 +368,28 @@ describe("StdinParser", () => { } }) - test("SS3 stays pending after timeout", () => { + test("SS3 timeout-flushed as unknown response", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1bO")) expect(snap(parser)).toEqual([]) clock.advance(10) - // ESC O is unambiguous — not flushed on timeout + expect(snap(parser)).toEqual([resp("unknown", "\x1bO")]) + } finally { + parser.destroy() + } + }) + + test("SS3 timeout flush does not swallow later text", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1bO")) expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1bO")]) + + parser.push(Buffer.from("a")) + expect(snap(parser)).toEqual([k("a")]) } finally { parser.destroy() } @@ -907,37 +921,37 @@ describe("StdinParser", () => { } }) - test("partial OSC stays pending after timeout", () => { + test("partial OSC flushes on timeout as unknown", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b]incomplete")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([]) + expect(snap(parser)).toEqual([resp("unknown", "\x1b]incomplete")]) } finally { parser.destroy() } }) - test("partial DCS stays pending after timeout", () => { + test("partial DCS flushes on timeout as unknown", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1bPpartial")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([]) + expect(snap(parser)).toEqual([resp("unknown", "\x1bPpartial")]) } finally { parser.destroy() } }) - test("partial APC stays pending after timeout", () => { + test("partial APC flushes on timeout as unknown", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b_partial")) expect(snap(parser)).toEqual([]) clock.advance(10) - expect(snap(parser)).toEqual([]) + expect(snap(parser)).toEqual([resp("unknown", "\x1b_partial")]) } finally { parser.destroy() } @@ -971,29 +985,46 @@ describe("StdinParser", () => { } }) - test("split OSC across reads reassembles after timeout", () => { + test("timed-out partial CSI resyncs on a later ESC", () => { const { parser, clock } = createTimedParser() try { - parser.push(Buffer.from("\x1b]52;c;")) + parser.push(Buffer.from("\x1b[118;5")) expect(snap(parser)).toEqual([]) clock.advance(10) expect(snap(parser)).toEqual([]) - parser.push(Buffer.from("dGVzdA==\x07")) - expect(snap(parser)).toEqual([resp("osc", "\x1b]52;c;dGVzdA==\x07")]) + + parser.push(Buffer.from("\x1b[A")) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[118;5"), k("up", { raw: "\x1b[A" })]) } finally { parser.destroy() } }) - test("split DCS across reads reassembles after timeout", () => { + test("partial OSC timeout flush does not swallow later text", () => { const { parser, clock } = createTimedParser() try { - parser.push(Buffer.from("\x1bP>|kit")) + parser.push(Buffer.from("\x1b]52;c;")) expect(snap(parser)).toEqual([]) clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b]52;c;")]) + + parser.push(Buffer.from("abc")) + expect(snap(parser)).toEqual([k("a"), k("b"), k("c")]) + } finally { + parser.destroy() + } + }) + + test("partial OSC timeout flush does not swallow later escape sequences", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b]52;c;")) expect(snap(parser)).toEqual([]) - parser.push(Buffer.from("ty(0.42.2)\x1b\\")) - expect(snap(parser)).toEqual([resp("dcs", "\x1bP>|kitty(0.42.2)\x1b\\")]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b]52;c;")]) + + parser.push(Buffer.from("\x1b[A")) + expect(snap(parser)).toEqual([k("up", { raw: "\x1b[A" })]) } finally { parser.destroy() } @@ -1182,7 +1213,7 @@ describe("StdinParser", () => { } }) - test("after timed-out ESC, partial [< stays pending after timeout", () => { + test("after timed-out ESC, partial [< waits, then timeout flushes as one response", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b")) @@ -1192,8 +1223,7 @@ describe("StdinParser", () => { parser.push(Buffer.from("[<35;20")) expect(snap(parser)).toEqual([]) clock.advance(10) - // esc_less_mouse is unambiguous — stays pending - expect(snap(parser)).toEqual([]) + expect(snap(parser)).toEqual([resp("unknown", "[<35;20")]) } finally { parser.destroy() } @@ -1285,6 +1315,36 @@ describe("StdinParser", () => { } }) + test("timed-out pending CSI does not rearm until more bytes arrive", () => { + const clock = new ManualClock() + let timeoutFlushes = 0 + let parser!: StdinParser + parser = new StdinParser({ + armTimeouts: true, + clock, + onTimeoutFlush: () => { + timeoutFlushes += 1 + parser.drain(() => {}) + }, + }) + + try { + parser.push(Buffer.from("\x1b[123")) + clock.advance(10) + expect(timeoutFlushes).toBe(1) + + clock.advance(50) + expect(timeoutFlushes).toBe(1) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from(";")) + clock.advance(10) + expect(timeoutFlushes).toBe(2) + } finally { + parser.destroy() + } + }) + test("timeout does not fire during paste mode", () => { const { parser, clock } = createTimedParser() try { @@ -1695,14 +1755,13 @@ describe("StdinParser", () => { ["bracketed paste end outside paste mode is a CSI response", "\x1b[201~", [resp("csi", "\x1b[201~")]], ]) - test("partial X10 stays pending after timeout", () => { + test("partial X10 times out as one unknown response", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b[M !")) expect(snap(parser)).toEqual([]) clock.advance(10) - // X10 mouse payload is unambiguous — stays pending - expect(snap(parser)).toEqual([]) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[M !")]) } finally { parser.destroy() } diff --git a/packages/core/src/lib/stdin-parser.ts b/packages/core/src/lib/stdin-parser.ts index bb300acd86..d8fef3c8e8 100644 --- a/packages/core/src/lib/stdin-parser.ts +++ b/packages/core/src/lib/stdin-parser.ts @@ -689,10 +689,15 @@ export class StdinParser { case "ss3": { if (this.cursor >= bytes.length) { - // ESC O already seen — unambiguously SS3, not a lone ESC. - // Wait for the next byte rather than timeout-flushing. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } if (byte === ESC) { @@ -745,11 +750,18 @@ export class StdinParser { case "csi": { if (this.cursor >= bytes.length) { - // ESC[ already seen — unambiguously CSI. Wait for final byte - // or interruption by a new ESC (handled below). Don't - // timeout-flush; split sequences across reads are common on - // Windows Terminal with kitty keyboard. - this.markPending() + if (!this.forceFlush) { + this.markPending() + return + } + + // CSI is the only framed escape state that can safely stay + // pending after a timeout. A later ESC will resync below, + // and fresh bytes can still complete split kitty sequences. + // Clear the timeout state so we do not keep re-arming a wakeup + // loop until new input arrives. + this.pendingSinceMs = null + this.forceFlush = false return } @@ -769,9 +781,15 @@ export class StdinParser { if (byte === 0x4d && this.cursor === this.unitStart + 2) { const end = this.cursor + 4 if (bytes.length < end) { - // Inside X10 mouse payload, wait for remaining bytes. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, bytes.length)) + this.state = { tag: "ground" } + this.consumePrefix(bytes.length) + continue } this.emitMouse(bytes.subarray(this.unitStart, end), "x10") @@ -840,9 +858,15 @@ export class StdinParser { // since the two-byte ESC \ can split across push() calls. case "osc": { if (this.cursor >= bytes.length) { - // ESC] already seen — wait for BEL or ESC \ terminator. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } if (this.state.sawEsc) { @@ -878,9 +902,15 @@ export class StdinParser { case "dcs": { if (this.cursor >= bytes.length) { - // ESC P already seen — wait for ESC \ terminator. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } if (this.state.sawEsc) { @@ -908,9 +938,15 @@ export class StdinParser { case "apc": { if (this.cursor >= bytes.length) { - // ESC _ already seen — wait for ESC \ terminator. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } if (this.state.sawEsc) { @@ -941,9 +977,15 @@ export class StdinParser { // opaque response so split mouse bytes never leak into text. case "esc_less_mouse": { if (this.cursor >= bytes.length) { - // Inside mouse sequence, wait for final byte. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } if ((byte >= 0x30 && byte <= 0x39) || byte === 0x3b) { @@ -972,9 +1014,15 @@ export class StdinParser { const end = this.unitStart + 5 if (bytes.length < end) { - // Inside X10 mouse payload, wait for remaining bytes. - this.markPending() - return + if (!this.forceFlush) { + this.markPending() + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, bytes.length)) + this.state = { tag: "ground" } + this.consumePrefix(bytes.length) + continue } this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, end)) diff --git a/packages/core/src/tests/renderer.input.test.ts b/packages/core/src/tests/renderer.input.test.ts index 7e01eda199..4dd341526d 100644 --- a/packages/core/src/tests/renderer.input.test.ts +++ b/packages/core/src/tests/renderer.input.test.ts @@ -1524,6 +1524,40 @@ test("partial SGR mouse stays pending on timeout, completes when rest arrives", expect(keypresses[0].name).toBe("x") }) +test("partial OSC flushed on timeout should not block later text", async () => { + const keypresses: KeyEvent[] = [] + currentRenderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b]52;c;")) + advanceCurrentClock() + expect(keypresses).toHaveLength(0) + + currentRenderer.stdin.emit("data", Buffer.from("abc")) + advanceCurrentClock() + + expect(keypresses).toHaveLength(3) + expect(keypresses.map((event) => event.name)).toEqual(["a", "b", "c"]) +}) + +test("partial OSC flushed on timeout should not block later escape sequences", async () => { + const keypresses: KeyEvent[] = [] + currentRenderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b]52;c;")) + advanceCurrentClock() + expect(keypresses).toHaveLength(0) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b[A")) + advanceCurrentClock() + + expect(keypresses).toHaveLength(1) + expect(keypresses[0].name).toBe("up") +}) + test("incomplete mouse input resets the timeout when more bytes arrive", async () => { const keypresses: KeyEvent[] = [] currentRenderer.keyInput.on("keypress", (event) => { From 43ce9509a41d480359d9b6f85d12c09de8629930 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Tue, 17 Mar 2026 21:35:11 +0100 Subject: [PATCH 3/3] fix(core): make CSI timeout deferral protocol-context aware Timed-out generic CSI prefixes are ambiguous, but reply families can be safely deferred when their protocol windows are known to be active. Add parser-level protocol context and explicit CSI sub-states so timeout deferral is driven by byte state + negotiated protocol state. Keep generic CSI conservative (flush on timeout) to prevent stale-prefix input capture, allow deferred reassembly for kitty keyboard, SGR mouse, private capability replies, pixel resolution responses, and explicit-width CPR only. --- packages/core/src/lib/stdin-parser.test.ts | 414 +++++++++++++- packages/core/src/lib/stdin-parser.ts | 525 +++++++++++++++++- packages/core/src/renderer.ts | 45 +- .../core/src/tests/renderer.input.test.ts | 54 ++ 4 files changed, 1023 insertions(+), 15 deletions(-) diff --git a/packages/core/src/lib/stdin-parser.test.ts b/packages/core/src/lib/stdin-parser.test.ts index eaf35508c6..83cf7503a8 100644 --- a/packages/core/src/lib/stdin-parser.test.ts +++ b/packages/core/src/lib/stdin-parser.test.ts @@ -957,20 +957,98 @@ describe("StdinParser", () => { } }) - test("partial CSI stays pending after timeout", () => { + test("partial generic CSI flushes on timeout as unknown", () => { const { parser, clock } = createTimedParser() try { parser.push(Buffer.from("\x1b[123")) expect(snap(parser)).toEqual([]) clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[123")]) + } finally { + parser.destroy() + } + }) + + test("partial kitty CSI stays pending after timeout", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[118;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) expect(snap(parser)).toEqual([]) } finally { parser.destroy() } }) - test("split CSI across reads reassembles after timeout", () => { + test("partial kitty CSI stays pending after timeout when split after first semicolon", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[97;")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("2u")) + expect(snap(parser)).toEqual([k("a", { shift: true, raw: "\x1b[97;2u" })]) + } finally { + parser.destroy() + } + }) + + test("partial kitty CSI stays pending after timeout for higher modifier bits", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[97;9")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("u")) + const event = parser.read() + expect(event?.type).toBe("key") + if (!event || event.type !== "key") throw new Error("expected key event") + expect(event.raw).toBe("\x1b[97;9u") + expect(event.key.name).toBe("a") + expect(event.key.super).toBe(true) + expect(parser.read()).toBeNull() + } finally { + parser.destroy() + } + }) + + test("partial kitty special-key CSI stays pending after timeout", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[1;1:")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("3A")) + expect(snap(parser)).toEqual([k("up", { raw: "\x1b[1;1:3A", eventType: "release" })]) + } finally { + parser.destroy() + } + }) + + test("partial SGR mouse CSI stays pending after timeout", () => { const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b[<35;20")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from(";5m")) + expect(snap(parser)).toEqual([sgr("\x1b[<35;20;5m", "move", 19, 4)]) + } finally { + parser.destroy() + } + }) + + test("split CSI across reads reassembles after timeout", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) try { // Kitty Ctrl+V release split across two reads parser.push(Buffer.from("\x1b[118;5")) @@ -985,8 +1063,52 @@ describe("StdinParser", () => { } }) - test("timed-out partial CSI resyncs on a later ESC", () => { + test("split kitty escape CSI across reads reassembles after timeout", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[27;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + parser.push(Buffer.from("u")) + expect(snap(parser)).toEqual([k("escape", { ctrl: true, raw: "\x1b[27;5u" })]) + } finally { + parser.destroy() + } + }) + + test("timed-out standard one-semicolon CSI key flushes before later text", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b[1;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[1;5")]) + + parser.push(Buffer.from("A")) + expect(snap(parser)).toEqual([k("a", { raw: "A", shift: true })]) + } finally { + parser.destroy() + } + }) + + test("timed-out one-semicolon CSI response flushes before later text", () => { const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b[24;80")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[24;80")]) + + parser.push(Buffer.from("R")) + expect(snap(parser)).toEqual([k("r", { raw: "R", shift: true })]) + } finally { + parser.destroy() + } + }) + + test("timed-out partial kitty CSI resyncs on a later ESC", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) try { parser.push(Buffer.from("\x1b[118;5")) expect(snap(parser)).toEqual([]) @@ -1000,6 +1122,51 @@ describe("StdinParser", () => { } }) + test("timed-out partial kitty CSI flushes before unrelated later text", () => { + const { parser, clock } = createTimedParser({ protocolContext: { kittyKeyboardEnabled: true } }) + try { + parser.push(Buffer.from("\x1b[118;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("a")) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[118;5"), k("a")]) + } finally { + parser.destroy() + } + }) + + test("partial generic CSI timeout flush does not swallow later text", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b[123")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[123")]) + + parser.push(Buffer.from("a")) + expect(snap(parser)).toEqual([k("a")]) + } finally { + parser.destroy() + } + }) + + test("partial large-parameter CSI flushes on timeout before later text", () => { + const { parser, clock } = createTimedParser() + try { + parser.push(Buffer.from("\x1b[80;120")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[80;120")]) + + parser.push(Buffer.from("a")) + expect(snap(parser)).toEqual([k("a")]) + } finally { + parser.destroy() + } + }) + test("partial OSC timeout flush does not swallow later text", () => { const { parser, clock } = createTimedParser() try { @@ -1031,6 +1198,212 @@ describe("StdinParser", () => { }) }) + describe("protocol context", () => { + test("partial explicit-width CPR stays pending after timeout when probe is active", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { explicitWidthCprActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[1;2")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("R")) + expect(snap(parser)).toEqual([resp("csi", "\x1b[1;2R")]) + } finally { + parser.destroy() + } + }) + + test("partial explicit-width CPR flushes before later text when probe is inactive", () => { + const { parser, clock } = createTimedParser() + + try { + parser.push(Buffer.from("\x1b[1;2")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[1;2")]) + + parser.push(Buffer.from("R")) + expect(snap(parser)).toEqual([k("r", { raw: "R", shift: true })]) + } finally { + parser.destroy() + } + }) + + test("partial pixel resolution response stays pending after timeout while query is active", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { pixelResolutionQueryActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[4;1080;192")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("0t")) + expect(snap(parser)).toEqual([resp("csi", "\x1b[4;1080;1920t")]) + } finally { + parser.destroy() + } + }) + + test("partial DECRPM stays pending after timeout while capability probe is active", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { privateCapabilityRepliesActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[?1016;2$")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("y")) + expect(snap(parser)).toEqual([resp("csi", "\x1b[?1016;2$y")]) + } finally { + parser.destroy() + } + }) + + test("partial DA1 stays pending after timeout while capability probe is active", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { privateCapabilityRepliesActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[?62;")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("c")) + expect(snap(parser)).toEqual([resp("csi", "\x1b[?62;c")]) + } finally { + parser.destroy() + } + }) + + test("timed-out modified CSI key still flushes before later final byte", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { explicitWidthCprActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[1;5")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from("A")) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[1;5"), k("a", { raw: "A", shift: true })]) + } finally { + parser.destroy() + } + }) + + test("generic row/col CPR does not reassemble during explicit-width probe window", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { explicitWidthCprActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[24;80")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[24;80")]) + + parser.push(Buffer.from("R")) + expect(snap(parser)).toEqual([k("r", { raw: "R", shift: true })]) + } finally { + parser.destroy() + } + }) + + test("deferred explicit-width CPR flushes when probe context is cleared", () => { + const { parser, clock } = createTimedParser({ + protocolContext: { explicitWidthCprActive: true }, + }) + + try { + parser.push(Buffer.from("\x1b[1;2")) + expect(snap(parser)).toEqual([]) + clock.advance(10) + expect(snap(parser)).toEqual([]) + + parser.updateProtocolContext({ explicitWidthCprActive: false }) + expect(snap(parser)).toEqual([resp("unknown", "\x1b[1;2")]) + } finally { + parser.destroy() + } + }) + + test("timed-out pending explicit-width CPR does not rearm until more bytes arrive", () => { + const clock = new ManualClock() + let timeoutFlushes = 0 + let parser!: StdinParser + parser = new StdinParser({ + armTimeouts: true, + clock, + protocolContext: { explicitWidthCprActive: true }, + onTimeoutFlush: () => { + timeoutFlushes += 1 + parser.drain(() => {}) + }, + }) + + try { + parser.push(Buffer.from("\x1b[1;2")) + clock.advance(10) + expect(timeoutFlushes).toBe(1) + + clock.advance(50) + expect(timeoutFlushes).toBe(1) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from(";")) + clock.advance(10) + expect(timeoutFlushes).toBe(2) + } finally { + parser.destroy() + } + }) + + test("timed-out pending private reply does not rearm until more bytes arrive", () => { + const clock = new ManualClock() + let timeoutFlushes = 0 + let parser!: StdinParser + parser = new StdinParser({ + armTimeouts: true, + clock, + protocolContext: { privateCapabilityRepliesActive: true }, + onTimeoutFlush: () => { + timeoutFlushes += 1 + parser.drain(() => {}) + }, + }) + + try { + parser.push(Buffer.from("\x1b[?1016;2$")) + clock.advance(10) + expect(timeoutFlushes).toBe(1) + + clock.advance(50) + expect(timeoutFlushes).toBe(1) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from(";")) + clock.advance(10) + expect(timeoutFlushes).toBe(2) + } finally { + parser.destroy() + } + }) + }) + describe("bracketed paste", () => { table([ ["simple paste", "\x1b[200~hello\x1b[201~", [paste("hello")]], @@ -1315,13 +1688,14 @@ describe("StdinParser", () => { } }) - test("timed-out pending CSI does not rearm until more bytes arrive", () => { + test("timed-out pending kitty CSI does not rearm until more bytes arrive", () => { const clock = new ManualClock() let timeoutFlushes = 0 let parser!: StdinParser parser = new StdinParser({ armTimeouts: true, clock, + protocolContext: { kittyKeyboardEnabled: true }, onTimeoutFlush: () => { timeoutFlushes += 1 parser.drain(() => {}) @@ -1329,7 +1703,37 @@ describe("StdinParser", () => { }) try { - parser.push(Buffer.from("\x1b[123")) + parser.push(Buffer.from("\x1b[118;5")) + clock.advance(10) + expect(timeoutFlushes).toBe(1) + + clock.advance(50) + expect(timeoutFlushes).toBe(1) + expect(snap(parser)).toEqual([]) + + parser.push(Buffer.from(";")) + clock.advance(10) + expect(timeoutFlushes).toBe(2) + } finally { + parser.destroy() + } + }) + + test("timed-out pending SGR mouse CSI does not rearm until more bytes arrive", () => { + const clock = new ManualClock() + let timeoutFlushes = 0 + let parser!: StdinParser + parser = new StdinParser({ + armTimeouts: true, + clock, + onTimeoutFlush: () => { + timeoutFlushes += 1 + parser.drain(() => {}) + }, + }) + + try { + parser.push(Buffer.from("\x1b[<35;20")) clock.advance(10) expect(timeoutFlushes).toBe(1) diff --git a/packages/core/src/lib/stdin-parser.ts b/packages/core/src/lib/stdin-parser.ts index d8fef3c8e8..8b5227ae7a 100644 --- a/packages/core/src/lib/stdin-parser.ts +++ b/packages/core/src/lib/stdin-parser.ts @@ -40,12 +40,20 @@ export type StdinEvent = sequence: string } +export interface StdinParserProtocolContext { + kittyKeyboardEnabled: boolean + privateCapabilityRepliesActive: boolean + pixelResolutionQueryActive: boolean + explicitWidthCprActive: boolean +} + export interface StdinParserOptions { timeoutMs?: number maxPendingBytes?: number armTimeouts?: boolean onTimeoutFlush?: () => void useKittyKeyboard?: boolean + protocolContext?: Partial clock?: Clock } @@ -59,6 +67,18 @@ type ParserState = | { tag: "esc" } | { tag: "ss3" } | { tag: "csi" } + | { tag: "csi_sgr_mouse"; part: number; hasDigit: boolean } + | { tag: "csi_sgr_mouse_deferred"; part: number; hasDigit: boolean } + | { tag: "csi_parametric"; semicolons: number; segments: number; hasDigit: boolean; firstParamValue: number | null } + | { + tag: "csi_parametric_deferred" + semicolons: number + segments: number + hasDigit: boolean + firstParamValue: number | null + } + | { tag: "csi_private_reply"; semicolons: number; hasDigit: boolean; sawDollar: boolean } + | { tag: "csi_private_reply_deferred"; semicolons: number; hasDigit: boolean; sawDollar: boolean } | { tag: "osc"; sawEsc: boolean } | { tag: "dcs"; sawEsc: boolean } | { tag: "apc"; sawEsc: boolean } @@ -86,6 +106,12 @@ const BRACKETED_PASTE_START = Buffer.from("\x1b[200~") const BRACKETED_PASTE_END = Buffer.from("\x1b[201~") const EMPTY_BYTES = new Uint8Array(0) const KEY_DECODER = new TextDecoder() +const DEFAULT_PROTOCOL_CONTEXT: StdinParserProtocolContext = { + kittyKeyboardEnabled: false, + privateCapabilityRepliesActive: false, + pixelResolutionQueryActive: false, + explicitWidthCprActive: false, +} // rxvt uses $-terminated CSI sequences for shifted function keys (e.g. ESC[2$). // Standard CSI treats $ as an intermediate byte, not a final, so we match these // explicitly to avoid waiting for a "real" final byte that never arrives. @@ -270,6 +296,117 @@ function isMouseSgrSequence(sequence: Uint8Array): boolean { return part === 2 && hasDigit } +function isAsciiDigit(byte: number): boolean { + return byte >= 0x30 && byte <= 0x39 +} + +interface ParametricCsiLike { + semicolons: number + segments: number + hasDigit: boolean + firstParamValue: number | null +} + +interface PrivateReplyCsiLike { + semicolons: number + hasDigit: boolean + sawDollar: boolean +} + +function parsePositiveDecimalPrefix(sequence: Uint8Array, start: number, endExclusive: number): number | null { + if (start >= endExclusive) return null + + let value = 0 + let sawDigit = false + for (let index = start; index < endExclusive; index += 1) { + const byte = sequence[index]! + if (!isAsciiDigit(byte)) return null + sawDigit = true + value = value * 10 + (byte - 0x30) + } + + return sawDigit ? value : null +} + +function canStillBeKittyU(state: ParametricCsiLike): boolean { + return state.semicolons >= 1 +} + +function canStillBeKittySpecial(state: ParametricCsiLike): boolean { + return state.semicolons === 1 && state.segments > 1 +} + +function canStillBeExplicitWidthCpr(state: ParametricCsiLike): boolean { + return state.firstParamValue === 1 && state.semicolons === 1 +} + +function canStillBePixelResolution(state: ParametricCsiLike): boolean { + return state.firstParamValue === 4 && state.semicolons === 2 +} + +function canDeferParametricCsi(state: ParametricCsiLike, context: StdinParserProtocolContext): boolean { + return ( + (context.kittyKeyboardEnabled && (canStillBeKittyU(state) || canStillBeKittySpecial(state))) || + (context.explicitWidthCprActive && canStillBeExplicitWidthCpr(state)) || + (context.pixelResolutionQueryActive && canStillBePixelResolution(state)) + ) +} + +function canCompleteDeferredParametricCsi( + state: ParametricCsiLike, + byte: number, + context: StdinParserProtocolContext, +): boolean { + if (context.kittyKeyboardEnabled) { + if (state.hasDigit && byte === 0x75) return true + if ( + state.hasDigit && + state.semicolons === 1 && + state.segments > 1 && + (byte === 0x7e || (byte >= 0x41 && byte <= 0x5a)) + ) { + return true + } + } + + if ( + context.explicitWidthCprActive && + state.hasDigit && + state.firstParamValue === 1 && + state.semicolons === 1 && + byte === 0x52 + ) { + return true + } + + if ( + context.pixelResolutionQueryActive && + state.hasDigit && + state.firstParamValue === 4 && + state.semicolons === 2 && + byte === 0x74 + ) { + return true + } + + return false +} + +function canDeferPrivateReplyCsi(context: StdinParserProtocolContext): boolean { + return context.privateCapabilityRepliesActive +} + +function canCompleteDeferredPrivateReplyCsi( + state: PrivateReplyCsiLike, + byte: number, + context: StdinParserProtocolContext, +): boolean { + if (!context.privateCapabilityRepliesActive) return false + if (state.sawDollar) return state.hasDigit && byte === 0x79 + if (byte === 0x63) return state.hasDigit || state.semicolons > 0 + return state.hasDigit && byte === 0x75 +} + function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array { if (left.length === 0) { return right @@ -364,6 +501,7 @@ export class StdinParser { private readonly useKittyKeyboard: boolean private readonly mouseParser = new MouseParser() private readonly clock: Clock + private protocolContext: StdinParserProtocolContext private timeoutId: TimerHandle | null = null private destroyed = false // When the current incomplete unit first appeared. Null when nothing is pending. @@ -392,12 +530,26 @@ export class StdinParser { this.onTimeoutFlush = options.onTimeoutFlush ?? null this.useKittyKeyboard = options.useKittyKeyboard ?? true this.clock = options.clock ?? SYSTEM_CLOCK + this.protocolContext = { + ...DEFAULT_PROTOCOL_CONTEXT, + kittyKeyboardEnabled: options.protocolContext?.kittyKeyboardEnabled ?? false, + privateCapabilityRepliesActive: options.protocolContext?.privateCapabilityRepliesActive ?? false, + pixelResolutionQueryActive: options.protocolContext?.pixelResolutionQueryActive ?? false, + explicitWidthCprActive: options.protocolContext?.explicitWidthCprActive ?? false, + } } public get bufferCapacity(): number { return this.pending.capacity } + public updateProtocolContext(patch: Partial): void { + this.ensureAlive() + this.protocolContext = { ...this.protocolContext, ...patch } + this.reconcileDeferredStateWithProtocolContext() + this.reconcileTimeoutState() + } + // Feeds raw stdin bytes into the parser. Converts as much as possible into // queued events and leaves at most one incomplete unit behind in pending. // @@ -755,14 +907,10 @@ export class StdinParser { return } - // CSI is the only framed escape state that can safely stay - // pending after a timeout. A later ESC will resync below, - // and fresh bytes can still complete split kitty sequences. - // Clear the timeout state so we do not keep re-arming a wakeup - // loop until new input arrives. - this.pendingSinceMs = null - this.forceFlush = false - return + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue } // A new ESC inside an incomplete CSI means the previous sequence @@ -814,6 +962,12 @@ export class StdinParser { } } + if (byte === 0x3c && this.cursor === this.unitStart + 2) { + this.cursor += 1 + this.state = { tag: "csi_sgr_mouse", part: 0, hasDigit: false } + continue + } + // Some terminals use ESC [[A..E / ESC [[5~ / ESC [[6~ variants. // Treat the second `[` immediately after ESC[ as part of the CSI // payload instead of as a final byte so parseKeypress() can match @@ -823,6 +977,27 @@ export class StdinParser { continue } + if (byte === 0x3f && this.cursor === this.unitStart + 2) { + this.cursor += 1 + this.state = { tag: "csi_private_reply", semicolons: 0, hasDigit: false, sawDollar: false } + continue + } + + if (byte === 0x3b) { + const firstParamValue = parsePositiveDecimalPrefix(bytes, this.unitStart + 2, this.cursor) + if (firstParamValue !== null) { + this.cursor += 1 + this.state = { + tag: "csi_parametric", + semicolons: 1, + segments: 1, + hasDigit: false, + firstParamValue, + } + continue + } + } + // Standard CSI final byte (0x40–0x7E). Check for bracketed paste // start, SGR mouse, or a regular CSI key/response. if (byte >= 0x40 && byte <= 0x7e) { @@ -853,6 +1028,320 @@ export class StdinParser { continue } + case "csi_sgr_mouse": { + if (this.cursor >= bytes.length) { + if (!this.forceFlush) { + this.markPending() + return + } + + this.state = { tag: "csi_sgr_mouse_deferred", part: this.state.part, hasDigit: this.state.hasDigit } + this.pendingSinceMs = null + this.forceFlush = false + return + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte)) { + this.cursor += 1 + this.state = { tag: "csi_sgr_mouse", part: this.state.part, hasDigit: true } + continue + } + + if (byte === 0x3b && this.state.hasDigit && this.state.part < 2) { + this.cursor += 1 + this.state = { tag: "csi_sgr_mouse", part: this.state.part + 1, hasDigit: false } + continue + } + + if (byte >= 0x40 && byte <= 0x7e) { + const end = this.cursor + 1 + const rawBytes = bytes.subarray(this.unitStart, end) + if (isMouseSgrSequence(rawBytes)) { + this.emitMouse(rawBytes, "sgr") + } else { + this.emitKeyOrResponse("csi", decodeUtf8(rawBytes)) + } + this.state = { tag: "ground" } + this.consumePrefix(end) + continue + } + + this.state = { tag: "csi" } + continue + } + + case "csi_sgr_mouse_deferred": { + if (this.cursor >= bytes.length) { + this.pendingSinceMs = null + this.forceFlush = false + return + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte) || byte === 0x3b || byte === 0x4d || byte === 0x6d) { + this.state = { tag: "csi_sgr_mouse", part: this.state.part, hasDigit: this.state.hasDigit } + continue + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + case "csi_parametric": { + if (this.cursor >= bytes.length) { + if (!this.forceFlush) { + this.markPending() + return + } + + if (canDeferParametricCsi(this.state, this.protocolContext)) { + this.state = { + tag: "csi_parametric_deferred", + semicolons: this.state.semicolons, + segments: this.state.segments, + hasDigit: this.state.hasDigit, + firstParamValue: this.state.firstParamValue, + } + this.pendingSinceMs = null + this.forceFlush = false + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte)) { + this.cursor += 1 + this.state = { + tag: "csi_parametric", + semicolons: this.state.semicolons, + segments: this.state.segments, + hasDigit: true, + firstParamValue: this.state.firstParamValue, + } + continue + } + + if (byte === 0x3a && this.state.hasDigit && this.state.segments < 3) { + this.cursor += 1 + this.state = { + tag: "csi_parametric", + semicolons: this.state.semicolons, + segments: this.state.segments + 1, + hasDigit: false, + firstParamValue: this.state.firstParamValue, + } + continue + } + + if (byte === 0x3b && this.state.semicolons < 2) { + this.cursor += 1 + this.state = { + tag: "csi_parametric", + semicolons: this.state.semicolons + 1, + segments: 1, + hasDigit: false, + firstParamValue: this.state.firstParamValue, + } + continue + } + + if (byte >= 0x40 && byte <= 0x7e) { + const end = this.cursor + 1 + this.emitKeyOrResponse("csi", decodeUtf8(bytes.subarray(this.unitStart, end))) + this.state = { tag: "ground" } + this.consumePrefix(end) + continue + } + + this.state = { tag: "csi" } + continue + } + + case "csi_parametric_deferred": { + if (this.cursor >= bytes.length) { + this.pendingSinceMs = null + this.forceFlush = false + return + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte) || byte === 0x3a || byte === 0x3b) { + this.state = { + tag: "csi_parametric", + semicolons: this.state.semicolons, + segments: this.state.segments, + hasDigit: this.state.hasDigit, + firstParamValue: this.state.firstParamValue, + } + continue + } + + if (canCompleteDeferredParametricCsi(this.state, byte, this.protocolContext)) { + this.state = { + tag: "csi_parametric", + semicolons: this.state.semicolons, + segments: this.state.segments, + hasDigit: this.state.hasDigit, + firstParamValue: this.state.firstParamValue, + } + continue + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + case "csi_private_reply": { + if (this.cursor >= bytes.length) { + if (!this.forceFlush) { + this.markPending() + return + } + + if (canDeferPrivateReplyCsi(this.protocolContext)) { + this.state = { + tag: "csi_private_reply_deferred", + semicolons: this.state.semicolons, + hasDigit: this.state.hasDigit, + sawDollar: this.state.sawDollar, + } + this.pendingSinceMs = null + this.forceFlush = false + return + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte)) { + this.cursor += 1 + this.state = { + tag: "csi_private_reply", + semicolons: this.state.semicolons, + hasDigit: true, + sawDollar: this.state.sawDollar, + } + continue + } + + if (byte === 0x3b) { + this.cursor += 1 + this.state = { + tag: "csi_private_reply", + semicolons: this.state.semicolons + 1, + hasDigit: false, + sawDollar: false, + } + continue + } + + if (byte === 0x24 && this.state.hasDigit && !this.state.sawDollar) { + this.cursor += 1 + this.state = { + tag: "csi_private_reply", + semicolons: this.state.semicolons, + hasDigit: true, + sawDollar: true, + } + continue + } + + if (byte >= 0x40 && byte <= 0x7e) { + const end = this.cursor + 1 + this.emitKeyOrResponse("csi", decodeUtf8(bytes.subarray(this.unitStart, end))) + this.state = { tag: "ground" } + this.consumePrefix(end) + continue + } + + this.state = { tag: "csi" } + continue + } + + case "csi_private_reply_deferred": { + if (this.cursor >= bytes.length) { + this.pendingSinceMs = null + this.forceFlush = false + return + } + + if (byte === ESC) { + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + + if (isAsciiDigit(byte) || byte === 0x3b || byte === 0x24) { + this.state = { + tag: "csi_private_reply", + semicolons: this.state.semicolons, + hasDigit: this.state.hasDigit, + sawDollar: this.state.sawDollar, + } + continue + } + + if (canCompleteDeferredPrivateReplyCsi(this.state, byte, this.protocolContext)) { + this.state = { + tag: "csi_private_reply", + semicolons: this.state.semicolons, + hasDigit: this.state.hasDigit, + sawDollar: this.state.sawDollar, + } + continue + } + + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + continue + } + // OSC sequences end at BEL or ESC \. DCS and APC end at ESC \ // only. The sawEsc flag tracks whether the previous byte was ESC, // since the two-byte ESC \ can split across push() calls. @@ -1195,6 +1684,26 @@ export class StdinParser { this.paste!.totalLength += bytes.length } + private reconcileDeferredStateWithProtocolContext(): void { + switch (this.state.tag) { + case "csi_parametric_deferred": + if (!canDeferParametricCsi(this.state, this.protocolContext)) { + this.emitOpaqueResponse("unknown", this.pending.view().subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + } + return + + case "csi_private_reply_deferred": + if (!canDeferPrivateReplyCsi(this.protocolContext)) { + this.emitOpaqueResponse("unknown", this.pending.view().subarray(this.unitStart, this.cursor)) + this.state = { tag: "ground" } + this.consumePrefix(this.cursor) + } + return + } + } + // Arms or disarms the timeout after every push(). If there's an incomplete // unit in the buffer, starts a timer. When the timer fires, it sets // forceFlush so the next read() converts the incomplete unit into one diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index a73d9c2b8a..10ef485091 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -35,7 +35,7 @@ import { parsePixelResolution, } from "./lib/terminal-capability-detection.js" import { type Clock, type TimerHandle, SystemClock } from "./lib/clock.js" -import { StdinParser, type StdinEvent } from "./lib/stdin-parser.js" +import { StdinParser, type StdinEvent, type StdinParserProtocolContext } from "./lib/stdin-parser.js" registerEnvVar({ name: "OTUI_DUMP_CAPTURES", @@ -637,6 +637,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.drainStdinParser() }, useKittyKeyboard: useKittyForParsing, + protocolContext: { + kittyKeyboardEnabled: useKittyForParsing, + privateCapabilityRepliesActive: false, + pixelResolutionQueryActive: false, + explicitWidthCprActive: false, + }, clock: this.clock, }) @@ -1040,10 +1046,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { public enableKittyKeyboard(flags: number = 0b00011): void { this.lib.enableKittyKeyboard(this.rendererPtr, flags) + this.updateStdinParserProtocolContext({ kittyKeyboardEnabled: true }) } public disableKittyKeyboard(): void { this.lib.disableKittyKeyboard(this.rendererPtr) + this.updateStdinParserProtocolContext({ kittyKeyboardEnabled: false }, true) } public set useThread(useThread: boolean) { @@ -1057,6 +1065,10 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (this._terminalIsSetup) return this._terminalIsSetup = true + this.updateStdinParserProtocolContext({ + privateCapabilityRepliesActive: true, + explicitWidthCprActive: true, + }) this.lib.setupTerminal(this.rendererPtr, this._useAlternateScreen) this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr) @@ -1072,6 +1084,13 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.capabilityTimeoutId = this.clock.setTimeout(() => { this.capabilityTimeoutId = null this.removeInputHandler(this.capabilityHandler) + this.updateStdinParserProtocolContext( + { + privateCapabilityRepliesActive: false, + explicitWidthCprActive: false, + }, + true, + ) }, 5000) if (this._useMouse) { @@ -1105,6 +1124,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.sequenceHandlers = this.sequenceHandlers.filter((candidate) => candidate !== handler) } + private updateStdinParserProtocolContext(patch: Partial, drain = false): void { + if (!this.stdinParser) return + this.stdinParser.updateProtocolContext(patch) + if (drain) this.drainStdinParser() + } + public subscribeOsc(handler: (sequence: string) => void): () => void { this.oscSubscribers.add(handler) return () => { @@ -1242,8 +1267,9 @@ export class CliRenderer extends EventEmitter implements RenderContext { const resolution = parsePixelResolution(sequence) if (resolution) { this._resolution = resolution - this.waitingForPixelResolution = false } + this.waitingForPixelResolution = false + this.updateStdinParserProtocolContext({ pixelResolutionQueryActive: false }, true) return true } return false @@ -1566,6 +1592,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private queryPixelResolution() { this.waitingForPixelResolution = true + this.updateStdinParserProtocolContext({ pixelResolutionQueryActive: true }) this.lib.queryPixelResolution(this.rendererPtr) } @@ -1784,6 +1811,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.disableMouse() this.removeExitListeners() + this.waitingForPixelResolution = false + this.updateStdinParserProtocolContext({ + privateCapabilityRepliesActive: false, + pixelResolutionQueryActive: false, + explicitWidthCprActive: false, + }) this.stdinParser?.reset() this.stdin.removeListener("data", this.stdinListener) this.lib.suspendRenderer(this.rendererPtr) @@ -1925,6 +1958,14 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._isRunning = false this.waitingForPixelResolution = false + this.updateStdinParserProtocolContext( + { + privateCapabilityRepliesActive: false, + pixelResolutionQueryActive: false, + explicitWidthCprActive: false, + }, + true, + ) this.setCapturedRenderable(undefined) try { diff --git a/packages/core/src/tests/renderer.input.test.ts b/packages/core/src/tests/renderer.input.test.ts index 4dd341526d..0a52e54313 100644 --- a/packages/core/src/tests/renderer.input.test.ts +++ b/packages/core/src/tests/renderer.input.test.ts @@ -1755,6 +1755,60 @@ test("delayed capability responses should be processed", async () => { expect(keypresses.map((k) => k.name)).toEqual(["a", "b", "c"]) }) +test("delayed explicit-width CPR stays in response path while setup probe is active", async () => { + const keypresses: KeyEvent[] = [] + currentRenderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + // @ts-expect-error - accessing private helper for test coverage + currentRenderer.updateStdinParserProtocolContext({ explicitWidthCprActive: true }) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b[1;2")) + advanceCurrentClock() + currentRenderer.stdin.emit("data", Buffer.from("R")) + advanceCurrentClock() + + expect(keypresses).toHaveLength(0) +}) + +test("delayed DECRPM stays in response path while capability probing is active", async () => { + const keypresses: KeyEvent[] = [] + currentRenderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + // @ts-expect-error - accessing private helper for test coverage + currentRenderer.updateStdinParserProtocolContext({ privateCapabilityRepliesActive: true }) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b[?1016;2$")) + advanceCurrentClock() + currentRenderer.stdin.emit("data", Buffer.from("y")) + advanceCurrentClock() + + expect(keypresses).toHaveLength(0) +}) + +test("delayed pixel resolution response stays in response path while query is active", async () => { + const keypresses: KeyEvent[] = [] + currentRenderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + // @ts-expect-error - accessing private property for testing + currentRenderer.waitingForPixelResolution = true + // @ts-expect-error - accessing private helper for test coverage + currentRenderer.updateStdinParserProtocolContext({ pixelResolutionQueryActive: true }) + + currentRenderer.stdin.emit("data", Buffer.from("\x1b[4;1080;192")) + advanceCurrentClock() + currentRenderer.stdin.emit("data", Buffer.from("0t")) + advanceCurrentClock() + + expect(keypresses).toHaveLength(0) + expect(currentRenderer.resolution).toEqual({ width: 1920, height: 1080 }) +}) + test("vscode minimal capability response", async () => { const keypresses: KeyEvent[] = [] currentRenderer.keyInput.on("keypress", (event) => {