@prometheus-ai/tui 0.5.3 → 0.5.8
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/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/stdin-buffer.ts
CHANGED
|
@@ -17,11 +17,32 @@
|
|
|
17
17
|
* MIT License - Copyright (c) 2025 opentui
|
|
18
18
|
*/
|
|
19
19
|
import { EventEmitter } from "events";
|
|
20
|
+
import { isKittyProtocolActive } from "./keys";
|
|
20
21
|
|
|
21
22
|
const ESC = "\x1b";
|
|
22
23
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
23
24
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
24
|
-
|
|
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;
|
|
25
46
|
/**
|
|
26
47
|
* Check if a string is a complete escape sequence or needs more data
|
|
27
48
|
*/
|
|
@@ -202,41 +223,67 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
|
|
|
202
223
|
|
|
203
224
|
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
|
|
204
225
|
const sequences: string[] = [];
|
|
226
|
+
const length = buffer.length;
|
|
205
227
|
let pos = 0;
|
|
206
228
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
// Find the end of this escape sequence
|
|
213
|
-
let
|
|
214
|
-
|
|
215
|
-
|
|
229
|
+
// Index-based scanning: this is the input hot path. Slicing the remaining
|
|
230
|
+
// buffer (or Array.from-ing it) per iteration would make plain-text bursts
|
|
231
|
+
// 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);
|
|
216
239
|
const status = isCompleteSequence(candidate);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
if (status === "incomplete") {
|
|
241
|
+
end++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// "\x1b\x1b" alone parses as "complete" (legacy alt+esc), but when the
|
|
245
|
+
// next byte opens a CSI/SS3 ("[" or "O") this is really ESC prefixing
|
|
246
|
+
// another sequence (meta-CSI, or a held Esc keypress joined by a
|
|
247
|
+
// follower). Consuming two bytes here would tear the follower and leak
|
|
248
|
+
// its tail as typed text (settings search filling with "[B" or
|
|
249
|
+
// "[<35;22;17M"). Keep growing; when the buffer ends here, hold the
|
|
250
|
+
// partial for the flush window so the disambiguating byte can arrive.
|
|
251
|
+
if (candidate === `${ESC}${ESC}`) {
|
|
252
|
+
if (end >= length) {
|
|
253
|
+
return { sequences, remainder: buffer.slice(pos) };
|
|
254
|
+
}
|
|
255
|
+
const next = buffer.charCodeAt(end);
|
|
256
|
+
if (next === 0x5b || next === 0x4f) {
|
|
257
|
+
end++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ESC + SGR mouse report is never a meta chord: alt-modified mouse
|
|
262
|
+
// reports carry the modifier in the button bits, not an ESC prefix.
|
|
263
|
+
// Deliver the bare ESC (a real Esc keypress) and the report separately.
|
|
264
|
+
if (candidate.startsWith(`${ESC}${ESC}[<`)) {
|
|
265
|
+
sequences.push(ESC, candidate.slice(1));
|
|
266
|
+
pos = end;
|
|
267
|
+
consumed = true;
|
|
228
268
|
break;
|
|
229
269
|
}
|
|
270
|
+
// "complete" — or "not-escape", which should not happen when
|
|
271
|
+
// starting with ESC; both consume the candidate.
|
|
272
|
+
sequences.push(candidate);
|
|
273
|
+
pos = end;
|
|
274
|
+
consumed = true;
|
|
275
|
+
break;
|
|
230
276
|
}
|
|
231
277
|
|
|
232
|
-
if (
|
|
233
|
-
return { sequences, remainder:
|
|
278
|
+
if (!consumed) {
|
|
279
|
+
return { sequences, remainder: buffer.slice(pos) };
|
|
234
280
|
}
|
|
235
281
|
} else {
|
|
236
282
|
// Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
pos
|
|
283
|
+
const codePoint = buffer.codePointAt(pos)!;
|
|
284
|
+
const charLength = codePoint > 0xffff ? 2 : 1;
|
|
285
|
+
sequences.push(buffer.slice(pos, pos + charLength));
|
|
286
|
+
pos += charLength;
|
|
240
287
|
}
|
|
241
288
|
}
|
|
242
289
|
|
|
@@ -249,6 +296,23 @@ export type StdinBufferOptions = {
|
|
|
249
296
|
* After this time, a genuinely incomplete escape is flushed.
|
|
250
297
|
*/
|
|
251
298
|
timeout?: number;
|
|
299
|
+
/**
|
|
300
|
+
* Maximum extra time (default: 150ms) an unambiguous escape partial — an
|
|
301
|
+
* SGR mouse prefix, or any dangling escape while the kitty keyboard
|
|
302
|
+
* protocol is active — is held past `timeout` waiting for its tail.
|
|
303
|
+
*/
|
|
304
|
+
partialHoldTimeout?: number;
|
|
305
|
+
/**
|
|
306
|
+
* Paste-mode inactivity watchdog (default: 1000ms). If no input arrives for
|
|
307
|
+
* this long while waiting for the bracketed-paste end marker, the paste is
|
|
308
|
+
* assumed truncated: accumulated bytes are delivered and input recovers.
|
|
309
|
+
*/
|
|
310
|
+
pasteTimeout?: number;
|
|
311
|
+
/**
|
|
312
|
+
* Paste-mode byte cap (default: 64 MiB). Exceeding it aborts paste mode the
|
|
313
|
+
* same way, bounding memory when the end marker never arrives.
|
|
314
|
+
*/
|
|
315
|
+
pasteByteLimit?: number;
|
|
252
316
|
};
|
|
253
317
|
|
|
254
318
|
export type StdinBufferEventMap = {
|
|
@@ -263,23 +327,29 @@ export type StdinBufferEventMap = {
|
|
|
263
327
|
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
264
328
|
#buffer: string = "";
|
|
265
329
|
#timeout?: NodeJS.Timeout;
|
|
330
|
+
#flushDeferral?: NodeJS.Timeout;
|
|
331
|
+
#partialHoldStartMs = 0;
|
|
266
332
|
readonly #timeoutMs: number;
|
|
333
|
+
readonly #partialHoldMaxMs: number;
|
|
334
|
+
readonly #pasteTimeoutMs: number;
|
|
335
|
+
readonly #pasteByteLimit: number;
|
|
267
336
|
#pasteMode: boolean = false;
|
|
268
|
-
#
|
|
337
|
+
#pasteChunks: string[] = [];
|
|
338
|
+
#pasteOverlap: string = "";
|
|
339
|
+
#pasteBytes = 0;
|
|
340
|
+
#pasteWatchdog?: NodeJS.Timeout;
|
|
269
341
|
#pendingKittyPrintableCodepoint: number | undefined;
|
|
342
|
+
#pendingKittyPrintableAtMs = 0;
|
|
270
343
|
|
|
271
344
|
constructor(options: StdinBufferOptions = {}) {
|
|
272
345
|
super();
|
|
273
346
|
this.#timeoutMs = options.timeout ?? 75;
|
|
347
|
+
this.#partialHoldMaxMs = options.partialHoldTimeout ?? PARTIAL_HOLD_MAX_MS;
|
|
348
|
+
this.#pasteTimeoutMs = options.pasteTimeout ?? PASTE_INACTIVITY_TIMEOUT_MS;
|
|
349
|
+
this.#pasteByteLimit = options.pasteByteLimit ?? PASTE_MAX_BYTES;
|
|
274
350
|
}
|
|
275
351
|
|
|
276
352
|
process(data: string | Buffer): void {
|
|
277
|
-
// Clear any pending timeout
|
|
278
|
-
if (this.#timeout) {
|
|
279
|
-
clearTimeout(this.#timeout);
|
|
280
|
-
this.#timeout = undefined;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
353
|
// Handle high-byte conversion (for compatibility with parseKeypress)
|
|
284
354
|
// If buffer has single byte > 127, convert to ESC + (byte - 128)
|
|
285
355
|
let str: string;
|
|
@@ -294,6 +364,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
294
364
|
str = data;
|
|
295
365
|
}
|
|
296
366
|
|
|
367
|
+
if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
|
|
368
|
+
// The buffered partial already hit its flush timeout. A new escape is
|
|
369
|
+
// a fresh sequence, not a tail; flush the stale partial first so the
|
|
370
|
+
// new sequence can be parsed from a clean buffer.
|
|
371
|
+
this.#flushExpired();
|
|
372
|
+
} else {
|
|
373
|
+
// Cancel any pending flush — new data may complete the buffered partial.
|
|
374
|
+
this.#clearFlushTimer();
|
|
375
|
+
}
|
|
376
|
+
|
|
297
377
|
if (str.length === 0 && this.#buffer.length === 0) {
|
|
298
378
|
this.#emitDataSequence("");
|
|
299
379
|
return;
|
|
@@ -302,24 +382,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
302
382
|
this.#buffer += str;
|
|
303
383
|
|
|
304
384
|
if (this.#pasteMode) {
|
|
305
|
-
|
|
385
|
+
const chunk = this.#buffer;
|
|
306
386
|
this.#buffer = "";
|
|
307
|
-
|
|
308
|
-
const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
309
|
-
if (endIndex !== -1) {
|
|
310
|
-
const pastedContent = this.#pasteBuffer.slice(0, endIndex);
|
|
311
|
-
const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
312
|
-
|
|
313
|
-
this.#pasteMode = false;
|
|
314
|
-
this.#pasteBuffer = "";
|
|
315
|
-
this.#pendingKittyPrintableCodepoint = undefined;
|
|
316
|
-
|
|
317
|
-
this.emit("paste", pastedContent);
|
|
318
|
-
|
|
319
|
-
if (remaining.length > 0) {
|
|
320
|
-
this.process(remaining);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
387
|
+
this.#consumePasteChunk(chunk);
|
|
323
388
|
return;
|
|
324
389
|
}
|
|
325
390
|
|
|
@@ -335,25 +400,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
335
400
|
|
|
336
401
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
337
402
|
this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
338
|
-
|
|
339
|
-
this.#pasteBuffer = this.#buffer;
|
|
403
|
+
const firstChunk = this.#buffer;
|
|
340
404
|
this.#buffer = "";
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
this.#pasteMode = false;
|
|
348
|
-
this.#pasteBuffer = "";
|
|
349
|
-
this.#pendingKittyPrintableCodepoint = undefined;
|
|
350
|
-
|
|
351
|
-
this.emit("paste", pastedContent);
|
|
352
|
-
|
|
353
|
-
if (remaining.length > 0) {
|
|
354
|
-
this.process(remaining);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
405
|
+
this.#pasteMode = true;
|
|
406
|
+
this.#pasteChunks = [];
|
|
407
|
+
this.#pasteOverlap = "";
|
|
408
|
+
this.#pasteBytes = 0;
|
|
409
|
+
this.#consumePasteChunk(firstChunk);
|
|
357
410
|
return;
|
|
358
411
|
}
|
|
359
412
|
|
|
@@ -365,32 +418,188 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
365
418
|
}
|
|
366
419
|
|
|
367
420
|
if (this.#buffer.length > 0) {
|
|
368
|
-
this.#
|
|
369
|
-
|
|
421
|
+
this.#armFlushTimer();
|
|
422
|
+
} else {
|
|
423
|
+
this.#partialHoldStartMs = 0;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
370
426
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Consume one chunk of paste-mode input. Chunks are accumulated in an array
|
|
429
|
+
* and only joined once the end marker arrives, so a large paste delivered in
|
|
430
|
+
* many small terminal reads stays O(total) instead of the O(total^2) cost of
|
|
431
|
+
* re-concatenating and rescanning the whole buffer on every chunk. A short
|
|
432
|
+
* overlap tail (end-marker length - 1) is carried across chunk boundaries so
|
|
433
|
+
* a marker split between two reads is still detected without rescanning.
|
|
434
|
+
*/
|
|
435
|
+
#consumePasteChunk(chunk: string): void {
|
|
436
|
+
const probe = this.#pasteOverlap + chunk;
|
|
437
|
+
if (probe.indexOf(BRACKETED_PASTE_END) === -1) {
|
|
438
|
+
this.#pasteChunks.push(chunk);
|
|
439
|
+
this.#pasteBytes += chunk.length;
|
|
440
|
+
const keep = BRACKETED_PASTE_END.length - 1;
|
|
441
|
+
this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe;
|
|
442
|
+
if (this.#pasteBytes > this.#pasteByteLimit) {
|
|
443
|
+
this.#abortPaste();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.#armPasteWatchdog();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// End marker arrived: join once and split at its first occurrence,
|
|
451
|
+
// matching the prior indexOf-from-start semantics exactly.
|
|
452
|
+
const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk;
|
|
453
|
+
const endIndex = flat.indexOf(BRACKETED_PASTE_END);
|
|
454
|
+
const pastedContent = flat.slice(0, endIndex);
|
|
455
|
+
const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
456
|
+
|
|
457
|
+
this.#clearPasteWatchdog();
|
|
458
|
+
this.#pasteMode = false;
|
|
459
|
+
this.#pasteChunks = [];
|
|
460
|
+
this.#pasteOverlap = "";
|
|
461
|
+
this.#pasteBytes = 0;
|
|
462
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
463
|
+
|
|
464
|
+
this.emit("paste", pastedContent);
|
|
465
|
+
|
|
466
|
+
if (remaining.length > 0) {
|
|
467
|
+
this.process(remaining);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Re-arm the paste-mode inactivity watchdog after each chunk. */
|
|
472
|
+
#armPasteWatchdog(): void {
|
|
473
|
+
if (this.#pasteWatchdog) clearTimeout(this.#pasteWatchdog);
|
|
474
|
+
this.#pasteWatchdog = setTimeout(() => {
|
|
475
|
+
this.#pasteWatchdog = undefined;
|
|
476
|
+
this.#abortPaste();
|
|
477
|
+
}, this.#pasteTimeoutMs);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#clearPasteWatchdog(): void {
|
|
481
|
+
if (this.#pasteWatchdog) {
|
|
482
|
+
clearTimeout(this.#pasteWatchdog);
|
|
483
|
+
this.#pasteWatchdog = undefined;
|
|
375
484
|
}
|
|
376
485
|
}
|
|
377
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Recover from a paste whose end marker never arrived (dropped or corrupted
|
|
489
|
+
* in transit, or past the byte cap): exit paste mode and deliver the
|
|
490
|
+
* accumulated bytes as a paste, so they are neither lost, replayed as
|
|
491
|
+
* keystrokes, nor accumulated forever while input appears dead.
|
|
492
|
+
*/
|
|
493
|
+
#abortPaste(): void {
|
|
494
|
+
this.#clearPasteWatchdog();
|
|
495
|
+
const content = this.#pasteChunks.join("");
|
|
496
|
+
this.#pasteMode = false;
|
|
497
|
+
this.#pasteChunks = [];
|
|
498
|
+
this.#pasteOverlap = "";
|
|
499
|
+
this.#pasteBytes = 0;
|
|
500
|
+
this.emit("paste", content);
|
|
501
|
+
}
|
|
502
|
+
|
|
378
503
|
#emitDataSequence(sequence: string): void {
|
|
379
504
|
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
380
|
-
if (
|
|
505
|
+
if (
|
|
506
|
+
rawCodepoint !== undefined &&
|
|
507
|
+
rawCodepoint === this.#pendingKittyPrintableCodepoint &&
|
|
508
|
+
Date.now() - this.#pendingKittyPrintableAtMs <= KITTY_PRINTABLE_DEDUP_WINDOW_MS
|
|
509
|
+
) {
|
|
381
510
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
382
511
|
return;
|
|
383
512
|
}
|
|
384
513
|
|
|
385
514
|
this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
|
|
515
|
+
if (this.#pendingKittyPrintableCodepoint !== undefined) {
|
|
516
|
+
this.#pendingKittyPrintableAtMs = Date.now();
|
|
517
|
+
}
|
|
386
518
|
this.emit("data", sequence);
|
|
387
519
|
}
|
|
388
520
|
|
|
389
|
-
|
|
521
|
+
/**
|
|
522
|
+
* setTimeout(0): when the event loop stalls past the timeout (heavy render)
|
|
523
|
+
* while the tail of a split escape is already queued on stdin, expired
|
|
524
|
+
* timers run before the poll phase that delivers the tail — flushing
|
|
525
|
+
* straight from the timer would tear the sequence apart and leak the tail
|
|
526
|
+
* as typed text. The zero-delay deferral runs on the next timers pass,
|
|
527
|
+
* after poll has had a chance to deliver the pending chunk to process()
|
|
528
|
+
* and cancel the deferral.
|
|
529
|
+
*/
|
|
530
|
+
#armFlushTimer(): void {
|
|
531
|
+
this.#timeout = setTimeout(() => {
|
|
532
|
+
this.#timeout = undefined;
|
|
533
|
+
this.#flushDeferral = setTimeout(() => {
|
|
534
|
+
this.#flushDeferral = undefined;
|
|
535
|
+
this.#flushExpired();
|
|
536
|
+
});
|
|
537
|
+
}, this.#timeoutMs);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#clearFlushTimer(): void {
|
|
390
541
|
if (this.#timeout) {
|
|
391
542
|
clearTimeout(this.#timeout);
|
|
392
543
|
this.#timeout = undefined;
|
|
393
544
|
}
|
|
545
|
+
if (this.#flushDeferral) {
|
|
546
|
+
clearTimeout(this.#flushDeferral);
|
|
547
|
+
this.#flushDeferral = undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* A deferred flush means the current buffer already waited for the
|
|
553
|
+
* incomplete-sequence timeout. If the next chunk starts a fresh escape, do
|
|
554
|
+
* not merge it into the stale partial. Keep ESC-backslash as a continuation
|
|
555
|
+
* for OSC/DCS/APC string terminators (`ST`).
|
|
556
|
+
*/
|
|
557
|
+
#isFreshEscapeAfterDeferredFlush(str: string): boolean {
|
|
558
|
+
if (!str.startsWith(ESC) || this.#buffer.length === 0) return false;
|
|
559
|
+
if (
|
|
560
|
+
str.startsWith(`${ESC}\\`) &&
|
|
561
|
+
(this.#buffer.startsWith(`${ESC}]`) ||
|
|
562
|
+
this.#buffer.startsWith(`${ESC}P`) ||
|
|
563
|
+
this.#buffer.startsWith(`${ESC}_`))
|
|
564
|
+
) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Whether the dangling partial cannot be a finished keypress and is worth
|
|
572
|
+
* holding for its tail instead of flushing:
|
|
573
|
+
* - SGR mouse prefixes (`\x1b[<…`) — no keyboard sequence uses them.
|
|
574
|
+
* - Any partial while the kitty keyboard protocol is active — the ESC key
|
|
575
|
+
* arrives as `\x1b[27u` and alt-chords as CSI-u, so a bare `\x1b` (or
|
|
576
|
+
* any unterminated escape) is always a split sequence, never a key.
|
|
577
|
+
*/
|
|
578
|
+
#shouldHoldPartial(): boolean {
|
|
579
|
+
return SGR_MOUSE_PARTIAL.test(this.#buffer) || isKittyProtocolActive();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
|
|
583
|
+
#flushExpired(): void {
|
|
584
|
+
if (this.#buffer.length === 0) {
|
|
585
|
+
this.#partialHoldStartMs = 0;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (this.#shouldHoldPartial()) {
|
|
589
|
+
if (this.#partialHoldStartMs === 0) this.#partialHoldStartMs = Date.now();
|
|
590
|
+
if (Date.now() - this.#partialHoldStartMs < this.#partialHoldMaxMs) {
|
|
591
|
+
this.#armFlushTimer();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
this.#partialHoldStartMs = 0;
|
|
596
|
+
for (const sequence of this.flush()) {
|
|
597
|
+
this.#emitDataSequence(sequence);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
flush(): string[] {
|
|
602
|
+
this.#clearFlushTimer();
|
|
394
603
|
|
|
395
604
|
if (this.#buffer.length === 0) {
|
|
396
605
|
return [];
|
|
@@ -403,14 +612,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
403
612
|
}
|
|
404
613
|
|
|
405
614
|
clear(): void {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.#timeout = undefined;
|
|
409
|
-
}
|
|
615
|
+
this.#clearFlushTimer();
|
|
616
|
+
this.#clearPasteWatchdog();
|
|
410
617
|
this.#buffer = "";
|
|
411
618
|
this.#pasteMode = false;
|
|
412
|
-
this.#
|
|
619
|
+
this.#pasteChunks = [];
|
|
620
|
+
this.#pasteOverlap = "";
|
|
621
|
+
this.#pasteBytes = 0;
|
|
413
622
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
623
|
+
this.#partialHoldStartMs = 0;
|
|
414
624
|
}
|
|
415
625
|
|
|
416
626
|
getBuffer(): string {
|