@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.
- package/CHANGELOG.md +14 -1
- package/dist/types/bracketed-paste.d.ts +19 -1
- package/dist/types/components/editor.d.ts +16 -0
- package/dist/types/fuzzy.d.ts +8 -0
- package/dist/types/terminal-capabilities.d.ts +2 -0
- package/package.json +3 -3
- package/src/bracketed-paste.ts +46 -7
- package/src/components/editor.ts +30 -3
- package/src/fuzzy.ts +68 -10
- package/src/stdin-buffer.ts +235 -217
- package/src/terminal-capabilities.ts +14 -4
- package/src/terminal.ts +21 -1
- package/src/tui.ts +230 -54
- package/src/utils.ts +2 -1
package/src/stdin-buffer.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|