From e6b3e1acf08358086175b8c998e0793f7dd90401 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 13:26:30 +0000 Subject: [PATCH] fix(tui): buffered split raw paste bursts before classifying Raw unbracketed paste data may span adjacent stdin reads. The prior call-local check drained a first chunk such as "line 1\r" before later lines could classify the burst, leaving the original per-line submit bug. - Held ESC-free break-bearing input in a fixed 10 ms classification window and appended adjacent raw reads before classification. - Coalesced candidates after two completed logical line breaks; replayed ambiguous candidates unchanged through the normal per-key path on expiry or before escape-bearing input. - Cleared and exposed pending candidates through the existing flush, clear, getBuffer, and destroy lifecycle. - Added regressions for split reads, a break-only boundary, and delayed replay of ordinary Enter input. Fixes #5841 --- packages/tui/CHANGELOG.md | 2 +- packages/tui/src/stdin-buffer.ts | 158 ++++++++++++++++++------- packages/tui/test/stdin-buffer.test.ts | 39 +++++- 3 files changed, 153 insertions(+), 46 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index caf66ebb8..83ea1178c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now coalesces an ESC-free raw burst of three or more CR/LF-delimited lines into a single paste event instead of per-key CR submits, while single Enters (including one batched with a following keystroke) still submit normally ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)). +- Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now collects adjacent ESC-free, CR/LF-bearing stdin reads in a fixed 10 ms classification window and coalesces three or more lines into one paste event, while ambiguous one-break input (including Enter batched with a following keystroke) is replayed unchanged ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)). ## [17.0.2] - 2026-07-17 diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 05cda1607..ed6b0b133 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -62,25 +62,38 @@ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024; // runs at most once per resolved report — never inside the growth loop. const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/; -// A raw stdin burst that carries two or more interior line breaks but no ESC -// byte is a multiline paste whose terminal delivered it without bracketed-paste -// markers (`\x1b[200~`…`\x1b[201~`). Observed in the Codex desktop embedded -// terminal on macOS (issue #5841): the Cmd+V payload reaches stdin as ordinary -// text with CR/LF line endings, so per-key splitting turns every CR into a -// submit and the block fragments into one message per line. -// -// The match requires content-break-content-break-content — three non-empty -// segments separated by two interior break runs (a CRLF pair counts as one -// run). Terminal read boundaries are not key boundaries: the event loop can -// batch a single Enter with a following keystroke into one read (`"a\rb"`), -// which is byte-identical to a two-line paste, so coalescing on a single -// interior break would swallow that Enter's submit. Two interior breaks cannot -// come from one Enter, and the real paste bug is always 3+ lines (the repro is -// three; the reported bursts were 11 and 23), so this keeps every ordinary -// Enter — lone (`\r`), trailing (`text\r\n`), bare run (`\r\r`), or batched -// (`"a\rb"`) — on the normal key path. ESC-free is required so bracketed pastes -// and CSI/mouse reports keep their path. -const RAW_MULTILINE_BURST = /[^\r\n][\r\n]+[^\r\n]+[\r\n]+[^\r\n]/; +// Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so +// adjacent stdin reads from one unmarked paste can be considered together. +// Fixed from the first break-bearing read (not an inactivity debounce): normal +// Enter latency and candidate memory remain bounded even under a continuous +// stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible. +const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10; + +/** + * Whether `text` has two completed logical line breaks (three line segments). + * + * A single Enter may be batched with surrounding keystrokes in one stdin read, + * so one break is ambiguous and must stay on the key path. CRLF counts as one + * logical break. Content after the second break completes the third segment; + * until then the classification window keeps buffering. + */ +function isRawMultilineBurst(text: string): boolean { + let breaks = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 0x0d) { + breaks++; + if (text.charCodeAt(i + 1) === 0x0a) i++; + continue; + } + if (code === 0x0a) { + breaks++; + continue; + } + if (breaks >= 2) return true; + } + return false; +} /** * Resolve the exclusive-end index of the escape sequence starting at `pos` @@ -384,6 +397,8 @@ export class StdinBuffer extends EventEmitter { #pendingKittyPrintableCodepoint: number | undefined; #pendingKittyPrintableAtMs = 0; #escapeSearchOffset = 0; + #rawPasteCandidate = ""; + #rawPasteTimer?: NodeJS.Timeout; constructor(options: StdinBufferOptions = {}) { super(); @@ -418,34 +433,49 @@ export class StdinBuffer extends EventEmitter { this.#clearFlushTimer(); } - if (str.length === 0 && this.#buffer.length === 0) { + if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) { this.#emitDataSequence(""); return; } - this.#buffer += str; - if (this.#pasteMode) { - const chunk = this.#buffer; - this.#buffer = ""; - this.#consumePasteChunk(chunk); + this.#consumePasteChunk(str); return; } - // Raw multiline paste burst without bracketed-paste markers (issue #5841): - // route it through the paste channel so its interior CR/LF stay content - // instead of each firing a submit. Guarded to ESC-free buffers, so real - // bracketed pastes, CSI keys, and mouse reports keep their normal path - // (a held escape partial always contains ESC and is never coalesced). - if (this.#buffer.indexOf(ESC) === -1 && RAW_MULTILINE_BURST.test(this.#buffer)) { - const content = this.#buffer; - this.#buffer = ""; - this.#escapeSearchOffset = 0; - this.#pendingKittyPrintableCodepoint = undefined; - this.emit("paste", content); + if (this.#rawPasteCandidate.length > 0) { + if (str.indexOf(ESC) !== -1) { + // Escape-bearing input cannot belong to an unmarked raw paste. + // Replay the ambiguous prefix as keys before parsing the escape. + this.#flushRawPasteCandidate(); + } else { + this.#rawPasteCandidate += str; + if (isRawMultilineBurst(this.#rawPasteCandidate)) { + this.#emitRawPasteCandidate(); + } + return; + } + } + + if ( + this.#buffer.length === 0 && + str.indexOf(ESC) === -1 && + (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1) + ) { + // Hold the first break-bearing read briefly. A split raw paste can + // then accumulate enough logical lines to classify; an ordinary + // Enter is replayed unchanged when the fixed window expires. + this.#rawPasteCandidate = str; + if (isRawMultilineBurst(str)) { + this.#emitRawPasteCandidate(); + } else { + this.#armRawPasteTimer(); + } return; } + this.#buffer += str; + const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START); if (startIndex !== -1) { if (startIndex > 0) { @@ -560,6 +590,46 @@ export class StdinBuffer extends EventEmitter { this.emit("paste", content); } + /** Start one fixed window from the first break-bearing raw read. */ + #armRawPasteTimer(): void { + if (this.#rawPasteTimer) return; + this.#rawPasteTimer = setTimeout(() => { + this.#rawPasteTimer = undefined; + this.#flushRawPasteCandidate(); + }, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS); + } + + #clearRawPasteTimer(): void { + if (this.#rawPasteTimer) { + clearTimeout(this.#rawPasteTimer); + this.#rawPasteTimer = undefined; + } + } + + #takeRawPasteCandidate(): string { + this.#clearRawPasteTimer(); + const content = this.#rawPasteCandidate; + this.#rawPasteCandidate = ""; + return content; + } + + /** Emit a classified raw multiline burst through the paste channel. */ + #emitRawPasteCandidate(): void { + const content = this.#takeRawPasteCandidate(); + this.#pendingKittyPrintableCodepoint = undefined; + this.emit("paste", content); + } + + /** Replay an ambiguous raw candidate as the original per-key data events. */ + #flushRawPasteCandidate(): void { + const content = this.#takeRawPasteCandidate(); + if (content.length === 0) return; + const result = extractCompleteSequences(content, 0); + for (const sequence of result.sequences) { + this.#emitDataSequence(sequence); + } + } + #emitDataSequence(sequence: string): void { const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; if ( @@ -661,8 +731,12 @@ export class StdinBuffer extends EventEmitter { flush(): string[] { this.#clearFlushTimer(); + const rawCandidate = this.#takeRawPasteCandidate(); + const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : []; + if (this.#buffer.length === 0) { - return []; + this.#pendingKittyPrintableCodepoint = undefined; + return sequences; } const buffered = this.#buffer; @@ -675,15 +749,19 @@ export class StdinBuffer extends EventEmitter { // emission swallows the double-escape gesture (#3857). Mirror the inline // split in `extractCompleteSequences` and deliver two ESC events. if (buffered === `${ESC}${ESC}`) { - return [ESC, ESC]; + sequences.push(ESC, ESC); + } else { + sequences.push(buffered); } - return [buffered]; + return sequences; } clear(): void { this.#clearFlushTimer(); this.#clearPasteWatchdog(); + this.#clearRawPasteTimer(); this.#buffer = ""; + this.#rawPasteCandidate = ""; this.#pasteMode = false; this.#pasteChunks = []; this.#pasteOverlap = ""; @@ -694,7 +772,7 @@ export class StdinBuffer extends EventEmitter { } getBuffer(): string { - return this.#buffer; + return `${this.#rawPasteCandidate}${this.#buffer}`; } destroy(): void { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index d430ad14c..aa908f1d0 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -579,36 +579,65 @@ describe("StdinBuffer", () => { expect(emittedSequences).toEqual([]); }); - it("leaves a single Enter batched with a following keystroke on the normal path", () => { + it("coalesces one raw paste split across adjacent stdin reads", () => { + processInput("line 1\r"); + expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + + processInput("line 2\rline 3"); + expect(emittedPaste).toEqual(["line 1\rline 2\rline 3"]); + expect(emittedSequences).toEqual([]); + }); + + it("coalesces a paste whose first line was already delivered before a break-only read", () => { + processInput("line 1"); + processInput("\r"); + processInput("line 2\rline 3"); + + expect(emittedSequences.join("")).toBe("line 1"); + expect(emittedPaste).toEqual(["\rline 2\rline 3"]); + }); + + it("leaves a single Enter batched with a following keystroke on the normal path", async () => { // The event loop can batch one Enter plus the next typed char into a // single stdin read; that is byte-identical to a two-line paste, so it // must keep the Enter's submit rather than coalesce (PR #5843 review). processInput("a\rb"); expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + await waitUntil(() => emittedSequences.length === 3); expect(emittedSequences).toEqual(["a", "\r", "b"]); }); - it("leaves a two-line burst on the normal path (one interior break is ambiguous)", () => { + it("leaves a two-line burst on the normal path (one interior break is ambiguous)", async () => { processInput("foo\rbar"); expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + await waitUntil(() => emittedSequences.length === 7); expect(emittedSequences).toEqual(["f", "o", "o", "\r", "b", "a", "r"]); }); - it("leaves a lone Enter as a normal submit keypress", () => { + it("leaves a lone Enter as a normal submit keypress", async () => { processInput("\r"); expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + await waitUntil(() => emittedSequences.length === 1); expect(emittedSequences).toEqual(["\r"]); }); - it("leaves typed text with a trailing Enter on the normal path", () => { + it("leaves typed text with a trailing Enter on the normal path", async () => { processInput("hello\r"); expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + await waitUntil(() => emittedSequences.length === 6); expect(emittedSequences).toEqual(["h", "e", "l", "l", "o", "\r"]); }); - it("does not coalesce a run of bare Enters", () => { + it("does not coalesce a run of bare Enters", async () => { processInput("\r\r"); expect(emittedPaste).toEqual([]); + expect(emittedSequences).toEqual([]); + await waitUntil(() => emittedSequences.length === 2); expect(emittedSequences).toEqual(["\r", "\r"]); });