@linxiraos/pi-tui 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * StdinBuffer buffers input and emits complete sequences.
3
+ *
4
+ * This is necessary because stdin data events can arrive in partial chunks,
5
+ * especially for escape sequences like mouse events. Without buffering,
6
+ * partial sequences can be misinterpreted as regular keypresses.
7
+ *
8
+ * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
9
+ * - Event 1: `\x1b`
10
+ * - Event 2: `[<35`
11
+ * - Event 3: `;20;5m`
12
+ *
13
+ * The buffer accumulates these until a complete sequence is detected.
14
+ * Call the `process()` method to feed input data.
15
+ *
16
+ * Based on code from OpenTUI (https://github.com/anomalyco/opentui)
17
+ * MIT License - Copyright (c) 2025 opentui
18
+ */
19
+ import { EventEmitter } from "events";
20
+ import { isKittyProtocolActive } from "./keys";
21
+
22
+ const ESC = "\x1b";
23
+ const BRACKETED_PASTE_START = "\x1b[200~";
24
+ const BRACKETED_PASTE_END = "\x1b[201~";
25
+ // Paste-mode recovery bounds: a lost/corrupted end marker (ssh/tmux
26
+ // truncation) must not hang input forever or grow memory unboundedly.
27
+ const PASTE_INACTIVITY_TIMEOUT_MS = 1000;
28
+ const PASTE_MAX_BYTES = 64 * 1024 * 1024;
29
+ // A buggy double-report (CSI-u event plus the bare printable for the same
30
+ // keypress) arrives in the same terminal write; a bare char that shows up
31
+ // later than this window is a real keystroke and must not be swallowed.
32
+ const KITTY_PRINTABLE_DEDUP_WINDOW_MS = 25;
33
+ // An SGR mouse report prefix is unambiguous: no keyboard sequence starts with
34
+ // `\x1b[<`, so a buffer still matching this is always the head of a split
35
+ // mouse report. Flushing it on timeout would deliver the tail as literal
36
+ // typed text to whatever component is focused (fullscreen overlays enable
37
+ // any-motion tracking, so report floods plus render stalls make the split
38
+ // routine — see the settings search leaking `[<35;8;16M`).
39
+ const SGR_MOUSE_PARTIAL = /^\x1b\[<[\d;]*$/;
40
+ // Upper bound on how long an unambiguous partial is held past the flush
41
+ // timeout before being delivered raw anyway (terminal died mid-sequence).
42
+ // This is also the worst-case added latency for a partial that never
43
+ // completes (e.g. a bare ESC delivered while the kitty-active flag is
44
+ // stale); keep it small.
45
+ const PARTIAL_HOLD_MAX_MS = 150;
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]$/;
64
+
65
+ // Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so
66
+ // adjacent stdin reads from one unmarked paste can be considered together.
67
+ // Fixed from the first break-bearing read (not an inactivity debounce): normal
68
+ // Enter latency and candidate memory remain bounded even under a continuous
69
+ // stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible.
70
+ const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10;
71
+
72
+ /**
73
+ * Whether `text` has two completed logical line breaks (three line segments).
74
+ *
75
+ * A single Enter may be batched with surrounding keystrokes in one stdin read,
76
+ * so one break is ambiguous and must stay on the key path. CRLF counts as one
77
+ * logical break. Content after the second break completes the third segment;
78
+ * until then the classification window keeps buffering.
79
+ */
80
+ function isRawMultilineBurst(text: string): boolean {
81
+ let breaks = 0;
82
+ for (let i = 0; i < text.length; i++) {
83
+ const code = text.charCodeAt(i);
84
+ if (code === 0x0d) {
85
+ breaks++;
86
+ if (text.charCodeAt(i + 1) === 0x0a) i++;
87
+ continue;
88
+ }
89
+ if (code === 0x0a) {
90
+ breaks++;
91
+ continue;
92
+ }
93
+ if (breaks >= 2) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Resolve the exclusive-end index of the escape sequence starting at `pos`
100
+ * (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
101
+ * for OSC/DCS/APC — it lets a chunked payload skip the prefix that a prior
102
+ * `process()` call already searched, so a large OSC 5522 image paste stays
103
+ * O(total) instead of O(total²).
104
+ *
105
+ * Meta-ESC (`\x1b\x1b…`) is not resolved here; the outer loop handles the
106
+ * disambiguation shared with the flush timer and the SGR mouse split. This
107
+ * helper returns -1 when the first byte after ESC is another ESC.
108
+ *
109
+ * Return codes:
110
+ * `end > pos` — complete sequence, exclusive end index.
111
+ * `-1` — incomplete, still under the per-type cap; buffer for more.
112
+ * `-2` — incomplete and the prefix already spans the per-type cap;
113
+ * the caller flushes it as raw bytes to guarantee progress.
114
+ */
115
+ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
116
+ if (pos + 1 >= length) return -1;
117
+ const next = buffer.charCodeAt(pos + 1);
118
+
119
+ switch (next) {
120
+ case 0x1b /* ESC */:
121
+ // Meta-ESC handled by the caller.
122
+ return -1;
123
+ case 0x5b /* [ */:
124
+ {
125
+ // CSI: ESC [ ... final byte in 0x40-0x7E.
126
+ if (pos + 2 >= length) return -1;
127
+ // Old-style X10 mouse: ESC [ M + 3 arbitrary bytes.
128
+ if (buffer.charCodeAt(pos + 2) === 0x4d /* M */) {
129
+ if (pos + 6 <= length) return pos + 6;
130
+ // Fewer than 6 bytes buffered is always under MAX_CSI_BYTES,
131
+ // so this is a plain "wait for more", never a cap flush.
132
+ return -1;
133
+ }
134
+ const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
135
+ const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c /* < */;
136
+ // No resume hint for CSI: `extractCompleteSequences` records
137
+ // hints only for OSC/DCS/APC. A partial CSI rescans from its
138
+ // head, bounded by the tight MAX_CSI_BYTES cap.
139
+ let i = pos + 2;
140
+ while (i < capEnd) {
141
+ const code = buffer.charCodeAt(i);
142
+ if (code >= 0x40 && code <= 0x7e) {
143
+ if (isSgrMouse) {
144
+ // SGR mouse only terminates on M/m. Any other final
145
+ // byte would be a malformed body — keep scanning to
146
+ // match the prior `isCompleteCsiSequence` semantics.
147
+ if (code !== 0x4d && code !== 0x6d) {
148
+ i++;
149
+ continue;
150
+ }
151
+ const payload = buffer.slice(pos + 2, i + 1);
152
+ if (SGR_MOUSE_COMPLETE.test(payload)) return i + 1;
153
+ // Malformed body ending in M/m — keep scanning for a
154
+ // real terminator. Bounded by capEnd.
155
+ i++;
156
+ continue;
157
+ }
158
+ return i + 1;
159
+ }
160
+ i++;
161
+ }
162
+ return length - pos >= MAX_CSI_BYTES ? -2 : -1;
163
+ }
164
+ case 0x5d /* ] */:
165
+ {
166
+ // OSC: ESC ] ... BEL or ST (ESC \). Scan is bounded to
167
+ // [searchFrom, scanLimit): `String#indexOf` has no end bound, so
168
+ // an unterminated payload delivered as one huge chunk would
169
+ // otherwise be scanned to the end of the buffer — past the cap
170
+ // this function exists to enforce. `resumeSearchFrom - 1` keeps
171
+ // the one-byte overlap so an `ESC \` split across chunks is
172
+ // still found (the prior call's trailing ESC is re-inspected).
173
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
174
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
175
+ for (let i = searchFrom; i < scanLimit; i++) {
176
+ const code = buffer.charCodeAt(i);
177
+ if (code === 0x07 /* BEL */) return i + 1;
178
+ if (code === 0x1b /* ESC */) {
179
+ // `ESC \` (ST) must end within the cap; a lone trailing
180
+ // ESC at the buffer edge stays incomplete and is
181
+ // re-examined next call via the resume overlap.
182
+ if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
183
+ }
184
+ }
185
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
186
+ }
187
+ case 0x50 /* P */:
188
+ case 0x5f /* _ */:
189
+ {
190
+ // DCS / APC: ESC P/_ ... ST (ESC \). Same bounded scan and
191
+ // split-ST overlap as the OSC branch, minus BEL.
192
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
193
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
194
+ for (let i = searchFrom; i < scanLimit; i++) {
195
+ if (
196
+ buffer.charCodeAt(i) === 0x1b /* ESC */ &&
197
+ i + 1 < scanLimit &&
198
+ buffer.charCodeAt(i + 1) === 0x5c /* \ */
199
+ ) {
200
+ return i + 2;
201
+ }
202
+ }
203
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
204
+ }
205
+ case 0x4f /* O */:
206
+ // SS3: ESC O + 1 char.
207
+ return pos + 3 <= length ? pos + 3 : -1;
208
+ default:
209
+ // Meta chord: ESC + 1 char.
210
+ return pos + 2;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Per-type cap used to flush the incomplete prefix when `resolveEscapeEnd`
216
+ * returns -2. The cap keeps issue-4073's malformed streamed CSI/OSC/…
217
+ * bounded in both work and memory.
218
+ */
219
+ function escapeCapFor(next: number): number {
220
+ // OSC/DCS/APC carry the large payloads (image paste, Sixel); CSI stays
221
+ // tight because real CSI keys/mouse/responses fit comfortably below 4 KiB.
222
+ return next === 0x5d || next === 0x50 || next === 0x5f ? MAX_STRING_SEQ_BYTES : MAX_CSI_BYTES;
223
+ }
224
+
225
+ /**
226
+ * Split accumulated buffer into complete sequences
227
+ */
228
+ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
229
+ const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
230
+ if (!match) return undefined;
231
+
232
+ const codepoint = parseInt(match[1]!, 10);
233
+ return codepoint >= 32 ? codepoint : undefined;
234
+ }
235
+
236
+ function extractCompleteSequences(
237
+ buffer: string,
238
+ resumeSearchFrom: number,
239
+ ): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
240
+ const sequences: string[] = [];
241
+ const length = buffer.length;
242
+ let pos = 0;
243
+
244
+ // Index-based scanning: this is the input hot path. Slicing the remaining
245
+ // buffer (or Array.from-ing it) per iteration would make plain-text bursts
246
+ // O(n²) — a 100KB non-bracketed paste must stay O(n).
247
+ //
248
+ // `resumeSearchFrom` applies only when the buffer starts with an
249
+ // incomplete OSC/DCS/APC we buffered on the previous call; once any
250
+ // bytes are consumed (pos advances past the leading escape), the hint no
251
+ // longer maps to the current buffer offsets and is discarded.
252
+ let hint = resumeSearchFrom;
253
+
254
+ while (pos < length) {
255
+ if (buffer.charCodeAt(pos) !== 0x1b) {
256
+ // Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
257
+ const codePoint = buffer.codePointAt(pos)!;
258
+ const charLength = codePoint > 0xffff ? 2 : 1;
259
+ sequences.push(buffer.slice(pos, pos + charLength));
260
+ pos += charLength;
261
+ hint = 0;
262
+ continue;
263
+ }
264
+
265
+ // `\x1b\x1b` is one of three things — see the outer switch below.
266
+ // Kept in the outer loop because it interacts with flush timing
267
+ // (bare `\x1b\x1b` is held for the timer chain) and with the SGR
268
+ // mouse split that splits `\x1b\x1b[<…` into `\x1b` + `\x1b[<…`.
269
+ if (pos + 1 < length && buffer.charCodeAt(pos + 1) === 0x1b) {
270
+ if (pos + 2 >= length) {
271
+ // Two real Esc keypresses bursted by terminal input batching:
272
+ // when the buffer ends here, hold the partial for the flush
273
+ // window so cases 1/2 can still arrive; if no follower
274
+ // arrives, `flush()` splits the held remainder into two ESC
275
+ // events (#3857).
276
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
277
+ }
278
+ const third = buffer.charCodeAt(pos + 2);
279
+ if (third !== 0x5b && third !== 0x4f) {
280
+ // ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
281
+ // emit the first ESC, then restart at the second ESC so
282
+ // downstream parsing still sees the Alt chord as one
283
+ // keypress (#3860 review).
284
+ sequences.push(ESC);
285
+ pos += 1;
286
+ hint = 0;
287
+ continue;
288
+ }
289
+ // ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
290
+ // resolve the inner escape's end from `pos + 1`. Consuming two
291
+ // bytes here would tear the follower and leak its tail as typed
292
+ // text (settings search filling with "[B" or "[<35;22;17M").
293
+ const innerEnd = resolveEscapeEnd(buffer, pos + 1, length, 0);
294
+ if (innerEnd === -1) {
295
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
296
+ }
297
+ if (innerEnd === -2) {
298
+ const cap = escapeCapFor(third);
299
+ const flushEnd = Math.min(length, pos + cap);
300
+ sequences.push(buffer.slice(pos, flushEnd));
301
+ pos = flushEnd;
302
+ hint = 0;
303
+ continue;
304
+ }
305
+ // ESC + SGR mouse is never a meta chord: alt-modified mouse
306
+ // reports carry the modifier in the button bits, not an ESC
307
+ // prefix. Deliver the bare ESC and the report separately.
308
+ if (third === 0x5b && buffer.charCodeAt(pos + 3) === 0x3c) {
309
+ sequences.push(ESC);
310
+ sequences.push(buffer.slice(pos + 1, innerEnd));
311
+ pos = innerEnd;
312
+ hint = 0;
313
+ continue;
314
+ }
315
+ sequences.push(buffer.slice(pos, innerEnd));
316
+ pos = innerEnd;
317
+ hint = 0;
318
+ continue;
319
+ }
320
+
321
+ // Single ESC — resolve directly. Hint carries over from the previous
322
+ // call only when we are still on the buffered escape (pos === 0).
323
+ const end = resolveEscapeEnd(buffer, pos, length, pos === 0 ? hint : 0);
324
+ if (end === -1) {
325
+ // Buffer for more. When this is the leading OSC/DCS/APC,
326
+ // remember how far we scanned so the next `process()` call
327
+ // resumes from there instead of rescanning the whole buffer.
328
+ const next = pos + 1 < length ? buffer.charCodeAt(pos + 1) : -1;
329
+ const nextHint = pos === 0 && (next === 0x5d || next === 0x50 || next === 0x5f) ? length : 0;
330
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint };
331
+ }
332
+ if (end === -2) {
333
+ const next = buffer.charCodeAt(pos + 1);
334
+ const cap = escapeCapFor(next);
335
+ const flushEnd = Math.min(length, pos + cap);
336
+ sequences.push(buffer.slice(pos, flushEnd));
337
+ pos = flushEnd;
338
+ hint = 0;
339
+ continue;
340
+ }
341
+ sequences.push(buffer.slice(pos, end));
342
+ pos = end;
343
+ hint = 0;
344
+ }
345
+
346
+ return { sequences, remainder: "", resumeSearchFrom: 0 };
347
+ }
348
+
349
+ export type StdinBufferOptions = {
350
+ /**
351
+ * Maximum time to wait for sequence completion (default: 75ms).
352
+ * After this time, a genuinely incomplete escape is flushed.
353
+ */
354
+ timeout?: number;
355
+ /**
356
+ * Maximum extra time (default: 150ms) an unambiguous escape partial — an
357
+ * SGR mouse prefix, or any dangling escape while the kitty keyboard
358
+ * protocol is active — is held past `timeout` waiting for its tail.
359
+ */
360
+ partialHoldTimeout?: number;
361
+ /**
362
+ * Paste-mode inactivity watchdog (default: 1000ms). If no input arrives for
363
+ * this long while waiting for the bracketed-paste end marker, the paste is
364
+ * assumed truncated: accumulated bytes are delivered and input recovers.
365
+ */
366
+ pasteTimeout?: number;
367
+ /**
368
+ * Paste-mode byte cap (default: 64 MiB). Exceeding it aborts paste mode the
369
+ * same way, bounding memory when the end marker never arrives.
370
+ */
371
+ pasteByteLimit?: number;
372
+ };
373
+
374
+ export type StdinBufferEventMap = {
375
+ data: [string];
376
+ paste: [string];
377
+ };
378
+
379
+ /**
380
+ * Buffers stdin input and emits complete sequences via the 'data' event.
381
+ * Handles partial escape sequences that arrive across multiple chunks.
382
+ */
383
+ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
384
+ #buffer: string = "";
385
+ #timeout?: NodeJS.Timeout;
386
+ #flushDeferral?: NodeJS.Timeout;
387
+ #partialHoldStartMs = 0;
388
+ readonly #timeoutMs: number;
389
+ readonly #partialHoldMaxMs: number;
390
+ readonly #pasteTimeoutMs: number;
391
+ readonly #pasteByteLimit: number;
392
+ #pasteMode: boolean = false;
393
+ #pasteChunks: string[] = [];
394
+ #pasteOverlap: string = "";
395
+ #pasteBytes = 0;
396
+ #pasteWatchdog?: NodeJS.Timeout;
397
+ #pendingKittyPrintableCodepoint: number | undefined;
398
+ #pendingKittyPrintableAtMs = 0;
399
+ #escapeSearchOffset = 0;
400
+ #rawPasteCandidate = "";
401
+ #rawPasteTimer?: NodeJS.Timeout;
402
+
403
+ constructor(options: StdinBufferOptions = {}) {
404
+ super();
405
+ this.#timeoutMs = options.timeout ?? 75;
406
+ this.#partialHoldMaxMs = options.partialHoldTimeout ?? PARTIAL_HOLD_MAX_MS;
407
+ this.#pasteTimeoutMs = options.pasteTimeout ?? PASTE_INACTIVITY_TIMEOUT_MS;
408
+ this.#pasteByteLimit = options.pasteByteLimit ?? PASTE_MAX_BYTES;
409
+ }
410
+
411
+ process(data: string | Buffer): void {
412
+ // Handle high-byte conversion (for compatibility with parseKeypress)
413
+ // If buffer has single byte > 127, convert to ESC + (byte - 128)
414
+ let str: string;
415
+ if (Buffer.isBuffer(data)) {
416
+ if (data.length === 1 && data[0]! > 127) {
417
+ const byte = data[0]! - 128;
418
+ str = `\x1b${String.fromCharCode(byte)}`;
419
+ } else {
420
+ str = data.toString();
421
+ }
422
+ } else {
423
+ str = data;
424
+ }
425
+
426
+ if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
427
+ // The buffered partial already hit its flush timeout. A new escape is
428
+ // a fresh sequence, not a tail; flush the stale partial first so the
429
+ // new sequence can be parsed from a clean buffer.
430
+ this.#flushExpired();
431
+ } else {
432
+ // Cancel any pending flush — new data may complete the buffered partial.
433
+ this.#clearFlushTimer();
434
+ }
435
+
436
+ if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) {
437
+ this.#emitDataSequence("");
438
+ return;
439
+ }
440
+
441
+ if (this.#pasteMode) {
442
+ this.#consumePasteChunk(str);
443
+ return;
444
+ }
445
+
446
+ if (this.#rawPasteCandidate.length > 0) {
447
+ if (str.indexOf(ESC) !== -1) {
448
+ // Escape-bearing input cannot belong to an unmarked raw paste.
449
+ // Replay the ambiguous prefix as keys before parsing the escape.
450
+ this.#flushRawPasteCandidate();
451
+ } else {
452
+ this.#rawPasteCandidate += str;
453
+ if (isRawMultilineBurst(this.#rawPasteCandidate)) {
454
+ this.#emitRawPasteCandidate();
455
+ }
456
+ return;
457
+ }
458
+ }
459
+
460
+ if (
461
+ this.#buffer.length === 0 &&
462
+ str.indexOf(ESC) === -1 &&
463
+ (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
464
+ ) {
465
+ // Hold the first break-bearing read briefly. A split raw paste can
466
+ // then accumulate enough logical lines to classify; an ordinary
467
+ // Enter is replayed unchanged when the fixed window expires.
468
+ this.#rawPasteCandidate = str;
469
+ if (isRawMultilineBurst(str)) {
470
+ this.#emitRawPasteCandidate();
471
+ } else {
472
+ this.#armRawPasteTimer();
473
+ }
474
+ return;
475
+ }
476
+
477
+ this.#buffer += str;
478
+
479
+ const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
480
+ if (startIndex !== -1) {
481
+ if (startIndex > 0) {
482
+ const beforePaste = this.#buffer.slice(0, startIndex);
483
+ const result = extractCompleteSequences(beforePaste, 0);
484
+ for (const sequence of result.sequences) {
485
+ this.#emitDataSequence(sequence);
486
+ }
487
+ }
488
+
489
+ this.#escapeSearchOffset = 0;
490
+ this.#pendingKittyPrintableCodepoint = undefined;
491
+ this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
492
+ const firstChunk = this.#buffer;
493
+ this.#buffer = "";
494
+ this.#pasteMode = true;
495
+ this.#pasteChunks = [];
496
+ this.#pasteOverlap = "";
497
+ this.#pasteBytes = 0;
498
+ this.#consumePasteChunk(firstChunk);
499
+ return;
500
+ }
501
+
502
+ const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
503
+ this.#buffer = result.remainder;
504
+ this.#escapeSearchOffset = result.resumeSearchFrom;
505
+
506
+ for (const sequence of result.sequences) {
507
+ this.#emitDataSequence(sequence);
508
+ }
509
+
510
+ if (this.#buffer.length > 0) {
511
+ this.#armFlushTimer();
512
+ } else {
513
+ this.#partialHoldStartMs = 0;
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Consume one chunk of paste-mode input. Chunks are accumulated in an array
519
+ * and only joined once the end marker arrives, so a large paste delivered in
520
+ * many small terminal reads stays O(total) instead of the O(total^2) cost of
521
+ * re-concatenating and rescanning the whole buffer on every chunk. A short
522
+ * overlap tail (end-marker length - 1) is carried across chunk boundaries so
523
+ * a marker split between two reads is still detected without rescanning.
524
+ */
525
+ #consumePasteChunk(chunk: string): void {
526
+ const probe = this.#pasteOverlap + chunk;
527
+ if (probe.indexOf(BRACKETED_PASTE_END) === -1) {
528
+ this.#pasteChunks.push(chunk);
529
+ this.#pasteBytes += chunk.length;
530
+ const keep = BRACKETED_PASTE_END.length - 1;
531
+ this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe;
532
+ if (this.#pasteBytes > this.#pasteByteLimit) {
533
+ this.#abortPaste();
534
+ return;
535
+ }
536
+ this.#armPasteWatchdog();
537
+ return;
538
+ }
539
+
540
+ // End marker arrived: join once and split at its first occurrence,
541
+ // matching the prior indexOf-from-start semantics exactly.
542
+ const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk;
543
+ const endIndex = flat.indexOf(BRACKETED_PASTE_END);
544
+ const pastedContent = flat.slice(0, endIndex);
545
+ const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length);
546
+
547
+ this.#clearPasteWatchdog();
548
+ this.#pasteMode = false;
549
+ this.#pasteChunks = [];
550
+ this.#pasteOverlap = "";
551
+ this.#pasteBytes = 0;
552
+ this.#pendingKittyPrintableCodepoint = undefined;
553
+
554
+ this.emit("paste", pastedContent);
555
+
556
+ if (remaining.length > 0) {
557
+ this.process(remaining);
558
+ }
559
+ }
560
+
561
+ /** Re-arm the paste-mode inactivity watchdog after each chunk. */
562
+ #armPasteWatchdog(): void {
563
+ if (this.#pasteWatchdog) clearTimeout(this.#pasteWatchdog);
564
+ this.#pasteWatchdog = setTimeout(() => {
565
+ this.#pasteWatchdog = undefined;
566
+ this.#abortPaste();
567
+ }, this.#pasteTimeoutMs);
568
+ }
569
+
570
+ #clearPasteWatchdog(): void {
571
+ if (this.#pasteWatchdog) {
572
+ clearTimeout(this.#pasteWatchdog);
573
+ this.#pasteWatchdog = undefined;
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Recover from a paste whose end marker never arrived (dropped or corrupted
579
+ * in transit, or past the byte cap): exit paste mode and deliver the
580
+ * accumulated bytes as a paste, so they are neither lost, replayed as
581
+ * keystrokes, nor accumulated forever while input appears dead.
582
+ */
583
+ #abortPaste(): void {
584
+ this.#clearPasteWatchdog();
585
+ const content = this.#pasteChunks.join("");
586
+ this.#pasteMode = false;
587
+ this.#pasteChunks = [];
588
+ this.#pasteOverlap = "";
589
+ this.#pasteBytes = 0;
590
+ this.emit("paste", content);
591
+ }
592
+
593
+ /** Start one fixed window from the first break-bearing raw read. */
594
+ #armRawPasteTimer(): void {
595
+ if (this.#rawPasteTimer) return;
596
+ this.#rawPasteTimer = setTimeout(() => {
597
+ this.#rawPasteTimer = undefined;
598
+ this.#flushRawPasteCandidate();
599
+ }, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS);
600
+ }
601
+
602
+ #clearRawPasteTimer(): void {
603
+ if (this.#rawPasteTimer) {
604
+ clearTimeout(this.#rawPasteTimer);
605
+ this.#rawPasteTimer = undefined;
606
+ }
607
+ }
608
+
609
+ #takeRawPasteCandidate(): string {
610
+ this.#clearRawPasteTimer();
611
+ const content = this.#rawPasteCandidate;
612
+ this.#rawPasteCandidate = "";
613
+ return content;
614
+ }
615
+
616
+ /** Emit a classified raw multiline burst through the paste channel. */
617
+ #emitRawPasteCandidate(): void {
618
+ const content = this.#takeRawPasteCandidate();
619
+ this.#pendingKittyPrintableCodepoint = undefined;
620
+ this.emit("paste", content);
621
+ }
622
+
623
+ /** Replay an ambiguous raw candidate as the original per-key data events. */
624
+ #flushRawPasteCandidate(): void {
625
+ const content = this.#takeRawPasteCandidate();
626
+ if (content.length === 0) return;
627
+ const result = extractCompleteSequences(content, 0);
628
+ for (const sequence of result.sequences) {
629
+ this.#emitDataSequence(sequence);
630
+ }
631
+ }
632
+
633
+ #emitDataSequence(sequence: string): void {
634
+ const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
635
+ if (
636
+ rawCodepoint !== undefined &&
637
+ rawCodepoint === this.#pendingKittyPrintableCodepoint &&
638
+ Date.now() - this.#pendingKittyPrintableAtMs <= KITTY_PRINTABLE_DEDUP_WINDOW_MS
639
+ ) {
640
+ this.#pendingKittyPrintableCodepoint = undefined;
641
+ return;
642
+ }
643
+
644
+ this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
645
+ if (this.#pendingKittyPrintableCodepoint !== undefined) {
646
+ this.#pendingKittyPrintableAtMs = Date.now();
647
+ }
648
+ this.emit("data", sequence);
649
+ }
650
+
651
+ /**
652
+ * setTimeout(0): when the event loop stalls past the timeout (heavy render)
653
+ * while the tail of a split escape is already queued on stdin, expired
654
+ * timers run before the poll phase that delivers the tail — flushing
655
+ * straight from the timer would tear the sequence apart and leak the tail
656
+ * as typed text. The zero-delay deferral runs on the next timers pass,
657
+ * after poll has had a chance to deliver the pending chunk to process()
658
+ * and cancel the deferral.
659
+ */
660
+ #armFlushTimer(): void {
661
+ this.#timeout = setTimeout(() => {
662
+ this.#timeout = undefined;
663
+ this.#flushDeferral = setTimeout(() => {
664
+ this.#flushDeferral = undefined;
665
+ this.#flushExpired();
666
+ });
667
+ }, this.#timeoutMs);
668
+ }
669
+
670
+ #clearFlushTimer(): void {
671
+ if (this.#timeout) {
672
+ clearTimeout(this.#timeout);
673
+ this.#timeout = undefined;
674
+ }
675
+ if (this.#flushDeferral) {
676
+ clearTimeout(this.#flushDeferral);
677
+ this.#flushDeferral = undefined;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * A deferred flush means the current buffer already waited for the
683
+ * incomplete-sequence timeout. If the next chunk starts a fresh escape, do
684
+ * not merge it into the stale partial. Keep ESC-backslash as a continuation
685
+ * for OSC/DCS/APC string terminators (`ST`).
686
+ */
687
+ #isFreshEscapeAfterDeferredFlush(str: string): boolean {
688
+ if (!str.startsWith(ESC) || this.#buffer.length === 0) return false;
689
+ if (
690
+ str.startsWith(`${ESC}\\`) &&
691
+ (this.#buffer.startsWith(`${ESC}]`) ||
692
+ this.#buffer.startsWith(`${ESC}P`) ||
693
+ this.#buffer.startsWith(`${ESC}_`))
694
+ ) {
695
+ return false;
696
+ }
697
+ return true;
698
+ }
699
+
700
+ /**
701
+ * Whether the dangling partial cannot be a finished keypress and is worth
702
+ * holding for its tail instead of flushing:
703
+ * - SGR mouse prefixes (`\x1b[<…`) — no keyboard sequence uses them.
704
+ * - Any partial while the kitty keyboard protocol is active — the ESC key
705
+ * arrives as `\x1b[27u` and alt-chords as CSI-u, so a bare `\x1b` (or
706
+ * any unterminated escape) is always a split sequence, never a key.
707
+ */
708
+ #shouldHoldPartial(): boolean {
709
+ return SGR_MOUSE_PARTIAL.test(this.#buffer) || isKittyProtocolActive();
710
+ }
711
+
712
+ /** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
713
+ #flushExpired(): void {
714
+ if (this.#buffer.length === 0) {
715
+ this.#partialHoldStartMs = 0;
716
+ return;
717
+ }
718
+ if (this.#shouldHoldPartial()) {
719
+ if (this.#partialHoldStartMs === 0) this.#partialHoldStartMs = Date.now();
720
+ if (Date.now() - this.#partialHoldStartMs < this.#partialHoldMaxMs) {
721
+ this.#armFlushTimer();
722
+ return;
723
+ }
724
+ }
725
+ this.#partialHoldStartMs = 0;
726
+ for (const sequence of this.flush()) {
727
+ this.#emitDataSequence(sequence);
728
+ }
729
+ }
730
+
731
+ flush(): string[] {
732
+ this.#clearFlushTimer();
733
+
734
+ const rawCandidate = this.#takeRawPasteCandidate();
735
+ const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : [];
736
+
737
+ if (this.#buffer.length === 0) {
738
+ this.#pendingKittyPrintableCodepoint = undefined;
739
+ return sequences;
740
+ }
741
+
742
+ const buffered = this.#buffer;
743
+ this.#buffer = "";
744
+ this.#escapeSearchOffset = 0;
745
+ this.#pendingKittyPrintableCodepoint = undefined;
746
+ // Bare double-ESC remainder (no disambiguating "[" / "O" arrived in time):
747
+ // two real Esc keypresses bursted by terminal batching, not a meta-CSI/SS3
748
+ // prefix. `parseKey` returns undefined for the combined chunk, so a single
749
+ // emission swallows the double-escape gesture (#3857). Mirror the inline
750
+ // split in `extractCompleteSequences` and deliver two ESC events.
751
+ if (buffered === `${ESC}${ESC}`) {
752
+ sequences.push(ESC, ESC);
753
+ } else {
754
+ sequences.push(buffered);
755
+ }
756
+ return sequences;
757
+ }
758
+
759
+ clear(): void {
760
+ this.#clearFlushTimer();
761
+ this.#clearPasteWatchdog();
762
+ this.#clearRawPasteTimer();
763
+ this.#buffer = "";
764
+ this.#rawPasteCandidate = "";
765
+ this.#pasteMode = false;
766
+ this.#pasteChunks = [];
767
+ this.#pasteOverlap = "";
768
+ this.#pasteBytes = 0;
769
+ this.#pendingKittyPrintableCodepoint = undefined;
770
+ this.#partialHoldStartMs = 0;
771
+ this.#escapeSearchOffset = 0;
772
+ }
773
+
774
+ getBuffer(): string {
775
+ return `${this.#rawPasteCandidate}${this.#buffer}`;
776
+ }
777
+
778
+ destroy(): void {
779
+ this.clear();
780
+ }
781
+ }