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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user