@oh-my-pi/pi-tui 16.2.12 → 16.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,171 +43,150 @@ const SGR_MOUSE_PARTIAL = /^\x1b\[<[\d;]*$/;
43
43
  // completes (e.g. a bare ESC delivered while the kitty-active flag is
44
44
  // stale); keep it small.
45
45
  const PARTIAL_HOLD_MAX_MS = 150;
46
- /**
47
- * Check if a string is a complete escape sequence or needs more data
48
- */
49
- function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
50
- if (!data.startsWith(ESC)) {
51
- return "not-escape";
52
- }
53
-
54
- if (data.length === 1) {
55
- return "incomplete";
56
- }
57
-
58
- const afterEsc = data.slice(1);
59
-
60
- // CSI sequences: ESC [
61
- if (afterEsc.startsWith("[")) {
62
- // Check for old-style mouse sequence: ESC[M + 3 bytes
63
- if (afterEsc.startsWith("[M")) {
64
- // Old-style mouse needs ESC[M + 3 bytes = 6 total
65
- return data.length >= 6 ? "complete" : "incomplete";
66
- }
67
- return isCompleteCsiSequence(data);
68
- }
69
-
70
- // OSC sequences: ESC ]
71
- if (afterEsc.startsWith("]")) {
72
- return isCompleteOscSequence(data);
73
- }
74
-
75
- // DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
76
- if (afterEsc.startsWith("P")) {
77
- return isCompleteDcsSequence(data);
78
- }
79
-
80
- // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
81
- if (afterEsc.startsWith("_")) {
82
- return isCompleteApcSequence(data);
83
- }
84
-
85
- // SS3 sequences: ESC O
86
- if (afterEsc.startsWith("O")) {
87
- // ESC O followed by a single character
88
- return afterEsc.length >= 2 ? "complete" : "incomplete";
89
- }
90
-
91
- // ESC-prefixed sequences (terminals with metaSendsEscape):
92
- // Only when the inner ESC starts a CSI ('[') or SS3 ('O') sequence.
93
- // Bare double-ESC (e.g. \x1b\x1bX) remains complete to avoid 10ms timeout lag.
94
- if (afterEsc.startsWith(ESC)) {
95
- const inner = data.slice(1);
96
- const third = inner.charCodeAt(1);
97
- if (third === 0x5b || third === 0x4f) {
98
- return isCompleteSequence(inner);
99
- }
100
- return "complete";
101
- }
102
-
103
- // Meta key sequences: ESC followed by a single character
104
- if (afterEsc.length === 1) {
105
- return "complete";
106
- }
107
-
108
- // Unknown escape sequence - treat as complete
109
- return "complete";
110
- }
46
+ // Escape-sequence length caps. `resolveEscapeEnd` scans within these bounds
47
+ // only, so a malformed CSI (missing final byte in `0x40-0x7E`) or a
48
+ // terminator-less OSC/DCS/APC cannot force `extractCompleteSequences` to
49
+ // re-inspect a growing prefix on every `process()` call a single call
50
+ // stays bounded work, and a streamed run of garbage bytes is flushed as
51
+ // raw sequences instead of accumulated forever (issue #4073 case A).
52
+ //
53
+ // CSI is intentionally tight: real CSI keys, mouse reports, and DECRQM
54
+ // replies are always well under 4 KiB. OSC/DCS/APC allow much larger
55
+ // payloads (kitty OSC 5522 clipboard reads, Sixel DCS, kitty graphics APC),
56
+ // so the string-terminator cap is generous.
57
+ const MAX_CSI_BYTES = 4096;
58
+ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
59
+
60
+ // SGR mouse report bodies live between `<` and the terminating `M`/`m`.
61
+ // Matched only when the trailing byte is a valid terminator, so the regex
62
+ // runs at most once per resolved report never inside the growth loop.
63
+ const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
111
64
 
112
65
  /**
113
- * Check if CSI sequence is complete
114
- * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
66
+ * Resolve the exclusive-end index of the escape sequence starting at `pos`
67
+ * (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
68
+ * for OSC/DCS/APC — it lets a chunked payload skip the prefix that a prior
69
+ * `process()` call already searched, so a large OSC 5522 image paste stays
70
+ * O(total) instead of O(total²).
71
+ *
72
+ * Meta-ESC (`\x1b\x1b…`) is not resolved here; the outer loop handles the
73
+ * disambiguation shared with the flush timer and the SGR mouse split. This
74
+ * helper returns -1 when the first byte after ESC is another ESC.
75
+ *
76
+ * Return codes:
77
+ * `end > pos` — complete sequence, exclusive end index.
78
+ * `-1` — incomplete, still under the per-type cap; buffer for more.
79
+ * `-2` — incomplete and the prefix already spans the per-type cap;
80
+ * the caller flushes it as raw bytes to guarantee progress.
115
81
  */
116
- function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
117
- if (!data.startsWith(`${ESC}[`)) {
118
- return "complete";
119
- }
120
-
121
- // Need at least ESC [ and one more character
122
- if (data.length < 3) {
123
- return "incomplete";
124
- }
125
-
126
- const payload = data.slice(2);
127
-
128
- // CSI sequences end with a byte in the range 0x40-0x7E (@-~)
129
- // This includes all letters and several special characters
130
- const lastChar = payload[payload.length - 1];
131
- const lastCharCode = lastChar.charCodeAt(0);
132
-
133
- if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
134
- // Special handling for SGR mouse sequences
135
- // Format: ESC[<B;X;Ym or ESC[<B;X;YM
136
- if (payload.startsWith("<")) {
137
- // Must have format: <digits;digits;digits[Mm]
138
- const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
139
- if (mouseMatch) {
140
- return "complete";
82
+ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
83
+ if (pos + 1 >= length) return -1;
84
+ const next = buffer.charCodeAt(pos + 1);
85
+
86
+ switch (next) {
87
+ case 0x1b /* ESC */:
88
+ // Meta-ESC handled by the caller.
89
+ return -1;
90
+ case 0x5b /* [ */:
91
+ {
92
+ // CSI: ESC [ ... final byte in 0x40-0x7E.
93
+ if (pos + 2 >= length) return -1;
94
+ // Old-style X10 mouse: ESC [ M + 3 arbitrary bytes.
95
+ if (buffer.charCodeAt(pos + 2) === 0x4d /* M */) {
96
+ if (pos + 6 <= length) return pos + 6;
97
+ // Fewer than 6 bytes buffered is always under MAX_CSI_BYTES,
98
+ // so this is a plain "wait for more", never a cap flush.
99
+ return -1;
100
+ }
101
+ const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
102
+ const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c /* < */;
103
+ // No resume hint for CSI: `extractCompleteSequences` records
104
+ // hints only for OSC/DCS/APC. A partial CSI rescans from its
105
+ // head, bounded by the tight MAX_CSI_BYTES cap.
106
+ let i = pos + 2;
107
+ while (i < capEnd) {
108
+ const code = buffer.charCodeAt(i);
109
+ if (code >= 0x40 && code <= 0x7e) {
110
+ if (isSgrMouse) {
111
+ // SGR mouse only terminates on M/m. Any other final
112
+ // byte would be a malformed body — keep scanning to
113
+ // match the prior `isCompleteCsiSequence` semantics.
114
+ if (code !== 0x4d && code !== 0x6d) {
115
+ i++;
116
+ continue;
117
+ }
118
+ const payload = buffer.slice(pos + 2, i + 1);
119
+ if (SGR_MOUSE_COMPLETE.test(payload)) return i + 1;
120
+ // Malformed body ending in M/m — keep scanning for a
121
+ // real terminator. Bounded by capEnd.
122
+ i++;
123
+ continue;
124
+ }
125
+ return i + 1;
126
+ }
127
+ i++;
128
+ }
129
+ return length - pos >= MAX_CSI_BYTES ? -2 : -1;
141
130
  }
142
- // If it ends with M or m but doesn't match the pattern, still incomplete
143
- if (lastChar === "M" || lastChar === "m") {
144
- // Check if we have the right structure
145
- const parts = payload.slice(1, -1).split(";");
146
- if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
147
- return "complete";
131
+ case 0x5d /* ] */:
132
+ {
133
+ // OSC: ESC ] ... BEL or ST (ESC \). Scan is bounded to
134
+ // [searchFrom, scanLimit): `String#indexOf` has no end bound, so
135
+ // an unterminated payload delivered as one huge chunk would
136
+ // otherwise be scanned to the end of the buffer — past the cap
137
+ // this function exists to enforce. `resumeSearchFrom - 1` keeps
138
+ // the one-byte overlap so an `ESC \` split across chunks is
139
+ // still found (the prior call's trailing ESC is re-inspected).
140
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
141
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
142
+ for (let i = searchFrom; i < scanLimit; i++) {
143
+ const code = buffer.charCodeAt(i);
144
+ if (code === 0x07 /* BEL */) return i + 1;
145
+ if (code === 0x1b /* ESC */) {
146
+ // `ESC \` (ST) must end within the cap; a lone trailing
147
+ // ESC at the buffer edge stays incomplete and is
148
+ // re-examined next call via the resume overlap.
149
+ if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
150
+ }
148
151
  }
152
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
149
153
  }
150
-
151
- return "incomplete";
152
- }
153
-
154
- return "complete";
155
- }
156
-
157
- return "incomplete";
158
- }
159
-
160
- /**
161
- * Check if OSC sequence is complete
162
- * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
163
- */
164
- function isCompleteOscSequence(data: string): "complete" | "incomplete" {
165
- if (!data.startsWith(`${ESC}]`)) {
166
- return "complete";
167
- }
168
-
169
- // OSC sequences end with ST (ESC \) or BEL (\x07)
170
- if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
171
- return "complete";
172
- }
173
-
174
- return "incomplete";
175
- }
176
-
177
- /**
178
- * Check if DCS (Device Control String) sequence is complete
179
- * DCS sequences: ESC P ... ST (where ST is ESC \)
180
- * Used for XTVersion responses like ESC P >| ... ESC \
181
- */
182
- function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
183
- if (!data.startsWith(`${ESC}P`)) {
184
- return "complete";
185
- }
186
-
187
- // DCS sequences end with ST (ESC \)
188
- if (data.endsWith(`${ESC}\\`)) {
189
- return "complete";
154
+ case 0x50 /* P */:
155
+ case 0x5f /* _ */:
156
+ {
157
+ // DCS / APC: ESC P/_ ... ST (ESC \). Same bounded scan and
158
+ // split-ST overlap as the OSC branch, minus BEL.
159
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
160
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
161
+ for (let i = searchFrom; i < scanLimit; i++) {
162
+ if (
163
+ buffer.charCodeAt(i) === 0x1b /* ESC */ &&
164
+ i + 1 < scanLimit &&
165
+ buffer.charCodeAt(i + 1) === 0x5c /* \ */
166
+ ) {
167
+ return i + 2;
168
+ }
169
+ }
170
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
171
+ }
172
+ case 0x4f /* O */:
173
+ // SS3: ESC O + 1 char.
174
+ return pos + 3 <= length ? pos + 3 : -1;
175
+ default:
176
+ // Meta chord: ESC + 1 char.
177
+ return pos + 2;
190
178
  }
191
-
192
- return "incomplete";
193
179
  }
194
180
 
195
181
  /**
196
- * Check if APC (Application Program Command) sequence is complete
197
- * APC sequences: ESC _ ... ST (where ST is ESC \)
198
- * Used for Kitty graphics responses like ESC _ G ... ESC \
182
+ * Per-type cap used to flush the incomplete prefix when `resolveEscapeEnd`
183
+ * returns -2. The cap keeps issue-4073's malformed streamed CSI/OSC/…
184
+ * bounded in both work and memory.
199
185
  */
200
- function isCompleteApcSequence(data: string): "complete" | "incomplete" {
201
- if (!data.startsWith(`${ESC}_`)) {
202
- return "complete";
203
- }
204
-
205
- // APC sequences end with ST (ESC \)
206
- if (data.endsWith(`${ESC}\\`)) {
207
- return "complete";
208
- }
209
-
210
- return "incomplete";
186
+ function escapeCapFor(next: number): number {
187
+ // OSC/DCS/APC carry the large payloads (image paste, Sixel); CSI stays
188
+ // tight because real CSI keys/mouse/responses fit comfortably below 4 KiB.
189
+ return next === 0x5d || next === 0x50 || next === 0x5f ? MAX_STRING_SEQ_BYTES : MAX_CSI_BYTES;
211
190
  }
212
191
 
213
192
  /**
@@ -221,7 +200,10 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
221
200
  return codepoint >= 32 ? codepoint : undefined;
222
201
  }
223
202
 
224
- function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
203
+ function extractCompleteSequences(
204
+ buffer: string,
205
+ resumeSearchFrom: number,
206
+ ): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
225
207
  const sequences: string[] = [];
226
208
  const length = buffer.length;
227
209
  let pos = 0;
@@ -229,75 +211,106 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
229
211
  // Index-based scanning: this is the input hot path. Slicing the remaining
230
212
  // buffer (or Array.from-ing it) per iteration would make plain-text bursts
231
213
  // O(n²) — a 100KB non-bracketed paste must stay O(n).
232
- while (pos < length) {
233
- if (buffer.charCodeAt(pos) === 0x1b) {
234
- // Find the end of this escape sequence by growing the candidate.
235
- let end = pos + 1;
236
- let consumed = false;
237
- while (end <= length) {
238
- const candidate = buffer.slice(pos, end);
239
- const status = isCompleteSequence(candidate);
240
- if (status === "incomplete") {
241
- end++;
242
- continue;
243
- }
244
- // "\x1b\x1b" is one of three things:
245
- // 1. ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
246
- // next byte is "[" or "O" — keep growing so the full sequence stays
247
- // together. Consuming two bytes here would tear the follower and
248
- // leak its tail as typed text (settings search filling with "[B"
249
- // or "[<35;22;17M").
250
- // 2. ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
251
- // emit the first ESC, then restart at the second ESC so downstream
252
- // parsing still sees the Alt chord as one keypress (#3860 review).
253
- // 3. Two real Esc keypresses bursted by terminal input batching:
254
- // when the buffer ends here, hold the partial for the flush window
255
- // so case 1/2 can still arrive; if no follower arrives, `flush()`
256
- // splits the held remainder into two ESC events (#3857).
257
- if (candidate === `${ESC}${ESC}`) {
258
- if (end >= length) {
259
- return { sequences, remainder: buffer.slice(pos) };
260
- }
261
- const next = buffer.charCodeAt(end);
262
- if (next === 0x5b || next === 0x4f) {
263
- end++;
264
- continue;
265
- }
266
- sequences.push(ESC);
267
- pos += 1;
268
- consumed = true;
269
- break;
270
- }
271
- // ESC + SGR mouse report is never a meta chord: alt-modified mouse
272
- // reports carry the modifier in the button bits, not an ESC prefix.
273
- // Deliver the bare ESC (a real Esc keypress) and the report separately.
274
- if (candidate.startsWith(`${ESC}${ESC}[<`)) {
275
- sequences.push(ESC, candidate.slice(1));
276
- pos = end;
277
- consumed = true;
278
- break;
279
- }
280
- // "complete" — or "not-escape", which should not happen when
281
- // starting with ESC; both consume the candidate.
282
- sequences.push(candidate);
283
- pos = end;
284
- consumed = true;
285
- break;
286
- }
214
+ //
215
+ // `resumeSearchFrom` applies only when the buffer starts with an
216
+ // incomplete OSC/DCS/APC we buffered on the previous call; once any
217
+ // bytes are consumed (pos advances past the leading escape), the hint no
218
+ // longer maps to the current buffer offsets and is discarded.
219
+ let hint = resumeSearchFrom;
287
220
 
288
- if (!consumed) {
289
- return { sequences, remainder: buffer.slice(pos) };
290
- }
291
- } else {
221
+ while (pos < length) {
222
+ if (buffer.charCodeAt(pos) !== 0x1b) {
292
223
  // Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
293
224
  const codePoint = buffer.codePointAt(pos)!;
294
225
  const charLength = codePoint > 0xffff ? 2 : 1;
295
226
  sequences.push(buffer.slice(pos, pos + charLength));
296
227
  pos += charLength;
228
+ hint = 0;
229
+ continue;
230
+ }
231
+
232
+ // `\x1b\x1b` is one of three things — see the outer switch below.
233
+ // Kept in the outer loop because it interacts with flush timing
234
+ // (bare `\x1b\x1b` is held for the timer chain) and with the SGR
235
+ // mouse split that splits `\x1b\x1b[<…` into `\x1b` + `\x1b[<…`.
236
+ if (pos + 1 < length && buffer.charCodeAt(pos + 1) === 0x1b) {
237
+ if (pos + 2 >= length) {
238
+ // Two real Esc keypresses bursted by terminal input batching:
239
+ // when the buffer ends here, hold the partial for the flush
240
+ // window so cases 1/2 can still arrive; if no follower
241
+ // arrives, `flush()` splits the held remainder into two ESC
242
+ // events (#3857).
243
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
244
+ }
245
+ const third = buffer.charCodeAt(pos + 2);
246
+ if (third !== 0x5b && third !== 0x4f) {
247
+ // ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
248
+ // emit the first ESC, then restart at the second ESC so
249
+ // downstream parsing still sees the Alt chord as one
250
+ // keypress (#3860 review).
251
+ sequences.push(ESC);
252
+ pos += 1;
253
+ hint = 0;
254
+ continue;
255
+ }
256
+ // ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
257
+ // resolve the inner escape's end from `pos + 1`. Consuming two
258
+ // bytes here would tear the follower and leak its tail as typed
259
+ // text (settings search filling with "[B" or "[<35;22;17M").
260
+ const innerEnd = resolveEscapeEnd(buffer, pos + 1, length, 0);
261
+ if (innerEnd === -1) {
262
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
263
+ }
264
+ if (innerEnd === -2) {
265
+ const cap = escapeCapFor(third);
266
+ const flushEnd = Math.min(length, pos + cap);
267
+ sequences.push(buffer.slice(pos, flushEnd));
268
+ pos = flushEnd;
269
+ hint = 0;
270
+ continue;
271
+ }
272
+ // ESC + SGR mouse is never a meta chord: alt-modified mouse
273
+ // reports carry the modifier in the button bits, not an ESC
274
+ // prefix. Deliver the bare ESC and the report separately.
275
+ if (third === 0x5b && buffer.charCodeAt(pos + 3) === 0x3c) {
276
+ sequences.push(ESC);
277
+ sequences.push(buffer.slice(pos + 1, innerEnd));
278
+ pos = innerEnd;
279
+ hint = 0;
280
+ continue;
281
+ }
282
+ sequences.push(buffer.slice(pos, innerEnd));
283
+ pos = innerEnd;
284
+ hint = 0;
285
+ continue;
286
+ }
287
+
288
+ // Single ESC — resolve directly. Hint carries over from the previous
289
+ // call only when we are still on the buffered escape (pos === 0).
290
+ const end = resolveEscapeEnd(buffer, pos, length, pos === 0 ? hint : 0);
291
+ if (end === -1) {
292
+ // Buffer for more. When this is the leading OSC/DCS/APC,
293
+ // remember how far we scanned so the next `process()` call
294
+ // resumes from there instead of rescanning the whole buffer.
295
+ const next = pos + 1 < length ? buffer.charCodeAt(pos + 1) : -1;
296
+ const nextHint = pos === 0 && (next === 0x5d || next === 0x50 || next === 0x5f) ? length : 0;
297
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint };
298
+ }
299
+ if (end === -2) {
300
+ const next = buffer.charCodeAt(pos + 1);
301
+ const cap = escapeCapFor(next);
302
+ const flushEnd = Math.min(length, pos + cap);
303
+ sequences.push(buffer.slice(pos, flushEnd));
304
+ pos = flushEnd;
305
+ hint = 0;
306
+ continue;
297
307
  }
308
+ sequences.push(buffer.slice(pos, end));
309
+ pos = end;
310
+ hint = 0;
298
311
  }
299
312
 
300
- return { sequences, remainder: "" };
313
+ return { sequences, remainder: "", resumeSearchFrom: 0 };
301
314
  }
302
315
 
303
316
  export type StdinBufferOptions = {
@@ -350,6 +363,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
350
363
  #pasteWatchdog?: NodeJS.Timeout;
351
364
  #pendingKittyPrintableCodepoint: number | undefined;
352
365
  #pendingKittyPrintableAtMs = 0;
366
+ #escapeSearchOffset = 0;
353
367
 
354
368
  constructor(options: StdinBufferOptions = {}) {
355
369
  super();
@@ -402,12 +416,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
402
416
  if (startIndex !== -1) {
403
417
  if (startIndex > 0) {
404
418
  const beforePaste = this.#buffer.slice(0, startIndex);
405
- const result = extractCompleteSequences(beforePaste);
419
+ const result = extractCompleteSequences(beforePaste, 0);
406
420
  for (const sequence of result.sequences) {
407
421
  this.#emitDataSequence(sequence);
408
422
  }
409
423
  }
410
424
 
425
+ this.#escapeSearchOffset = 0;
411
426
  this.#pendingKittyPrintableCodepoint = undefined;
412
427
  this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
413
428
  const firstChunk = this.#buffer;
@@ -420,8 +435,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
420
435
  return;
421
436
  }
422
437
 
423
- const result = extractCompleteSequences(this.#buffer);
438
+ const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
424
439
  this.#buffer = result.remainder;
440
+ this.#escapeSearchOffset = result.resumeSearchFrom;
425
441
 
426
442
  for (const sequence of result.sequences) {
427
443
  this.#emitDataSequence(sequence);
@@ -617,6 +633,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
617
633
 
618
634
  const buffered = this.#buffer;
619
635
  this.#buffer = "";
636
+ this.#escapeSearchOffset = 0;
620
637
  this.#pendingKittyPrintableCodepoint = undefined;
621
638
  // Bare double-ESC remainder (no disambiguating "[" / "O" arrived in time):
622
639
  // two real Esc keypresses bursted by terminal batching, not a meta-CSI/SS3
@@ -639,6 +656,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
639
656
  this.#pasteBytes = 0;
640
657
  this.#pendingKittyPrintableCodepoint = undefined;
641
658
  this.#partialHoldStartMs = 0;
659
+ this.#escapeSearchOffset = 0;
642
660
  }
643
661
 
644
662
  getBuffer(): string {
@@ -144,6 +144,18 @@ export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
144
144
  return Boolean(env.TMUX);
145
145
  }
146
146
 
147
+ /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
148
+ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
149
+ // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
150
+ // signals. TERM can also survive when those are stripped (`sudo` without -E,
151
+ // `su`, env-sanitizing launchers/ssh). Do not use CMUX_SOCKET_PATH here: it is
152
+ // a CLI socket override and can be set outside a CMUX terminal.
153
+ if (env.TMUX || env.STY || env.ZELLIJ) return true;
154
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID) return true;
155
+ const term = env.TERM?.toLowerCase() ?? "";
156
+ return term.startsWith("tmux") || term.startsWith("screen");
157
+ }
158
+
147
159
  /**
148
160
  * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
149
161
  * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
@@ -256,8 +268,7 @@ export function shouldEnableSynchronizedOutputByDefault(
256
268
  // older tmux/screen synchronized-output handling is flaky and a mux may not
257
269
  // pass DEC 2026 to the outer host. The DECRQM probe re-enables sync when the
258
270
  // mux reports `?2026` supported.
259
- const term = env.TERM?.toLowerCase() ?? "";
260
- if (env.TMUX || env.STY || env.ZELLIJ || term.startsWith("tmux") || term.startsWith("screen")) {
271
+ if (isInsideTerminalMultiplexer(env)) {
261
272
  return false;
262
273
  }
263
274
 
@@ -301,8 +312,7 @@ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS.
301
312
  if (terminalId !== "kitty") return false;
302
313
  const kill = env.PI_NO_DECCARA;
303
314
  if (kill && kill !== "0" && kill.toLowerCase() !== "false") return false;
304
- const term = env.TERM?.toLowerCase() ?? "";
305
- if (env.TMUX || env.STY || env.ZELLIJ || term.startsWith("tmux") || term.startsWith("screen")) {
315
+ if (isInsideTerminalMultiplexer(env)) {
306
316
  return false;
307
317
  }
308
318
  return true;
package/src/terminal.ts CHANGED
@@ -691,8 +691,28 @@ export class ProcessTerminal implements Terminal {
691
691
  // In-band resize report (DEC mode 2048): \x1b[48;rows;cols;yPixels;xPixels t
692
692
  const inBandResizePattern = /^\x1b\[48;(\d+);(\d+);(\d+);(\d+)t$/;
693
693
 
694
- // Forward individual sequences to the input handler
695
694
  this.#stdinBuffer.on("data", (sequence: string) => {
695
+ // Fast path for plain-text bytes: every escape-probe regex below
696
+ // anchors on `^\x1b…`, so a byte that is not ESC can never match. A
697
+ // non-bracketed paste of N printable chars arrives as N per-scalar
698
+ // `data` events; running the full probe suite per event turns a
699
+ // 100 KB paste into ~600K regex executions and blocks the event
700
+ // loop. Skip straight to the input handler when no reassembly
701
+ // buffer is holding state that a non-ESC continuation could feed
702
+ // (issue #4073 case C).
703
+ if (
704
+ (sequence.length === 0 || sequence.charCodeAt(0) !== 0x1b) &&
705
+ this.#privateCsiResponseBuffer.length === 0 &&
706
+ this.#inBandResizeBuffer.length === 0 &&
707
+ this.#osc11ResponseBuffer.length === 0 &&
708
+ this.#osc99ResponseBuffer.length === 0
709
+ ) {
710
+ if (this.#inputHandler) {
711
+ this.#inputHandler(sequence);
712
+ }
713
+ return;
714
+ }
715
+
696
716
  // Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
697
717
  // When the terminal writes the response slowly enough that the StdinBuffer's
698
718
  // flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as