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
This commit is contained in:
roboomp
2026-07-17 13:26:30 +00:00
parent bacad80b56
commit e6b3e1acf0
3 changed files with 153 additions and 46 deletions
+1 -1
View File
@@ -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
+118 -40
View File
@@ -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<StdinBufferEventMap> {
#pendingKittyPrintableCodepoint: number | undefined;
#pendingKittyPrintableAtMs = 0;
#escapeSearchOffset = 0;
#rawPasteCandidate = "";
#rawPasteTimer?: NodeJS.Timeout;
constructor(options: StdinBufferOptions = {}) {
super();
@@ -418,34 +433,49 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
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<StdinBufferEventMap> {
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<StdinBufferEventMap> {
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<StdinBufferEventMap> {
// 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<StdinBufferEventMap> {
}
getBuffer(): string {
return this.#buffer;
return `${this.#rawPasteCandidate}${this.#buffer}`;
}
destroy(): void {
+34 -5
View File
@@ -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"]);
});