@linxiraos/pi-tui 1.3.0 → 1.3.1

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.
@@ -58,7 +58,7 @@ const PARTIAL_HOLD_MAX_MS = 150;
58
58
  // so the string-terminator cap is generous.
59
59
  const MAX_CSI_BYTES = 4096;
60
60
  const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
61
- // Torn-string discard bounds. When a capped or timeout-expired OSC/DCS/APC
61
+ // Torn-string discard bounds. When a capped OSC/DCS/APC
62
62
  // partial is dropped, the bytes that follow are mid-string payload (base64
63
63
  // clipboard data, Sixel, …) up to the next ST/BEL. Replaying them as
64
64
  // keystrokes types kilobytes of garbage into the focused component (kitty
@@ -69,6 +69,22 @@ const STRING_DISCARD_MAX_BYTES = 2 * MAX_STRING_SEQ_BYTES;
69
69
  const STRING_DISCARD_INACTIVITY_MS = 1000;
70
70
  // Partial that can only be the head of an OSC/DCS/APC string sequence.
71
71
  const STRING_SEQ_PARTIAL = /^\x1b[\]P_]/;
72
+ // A pending string head is abandoned after this long without terminal-shaped
73
+ // progress. Generous: a slow clipboard owner or SSH link may stall a packet
74
+ // for a while, and abandoning a live packet types nothing but loses it.
75
+ const STRING_ABANDON_MS = 5000;
76
+ // How long an abandoned head stays dormant waiting for a late tail. Bounded
77
+ // so a stale packet cannot absorb unrelated multi-byte input later on; a
78
+ // resumed head that is abandoned again is dropped for good.
79
+ const STRING_DORMANT_MS = 10_000;
80
+ // Openings of the string replies omp receives, matched while still partial
81
+ // (each alternative accepts any prefix of its opener) so a legacy Alt chord
82
+ // that diverges is recognized as soon as it does:
83
+ // - OSC: numeric command then `;` (`\x1b]11;`, `\x1b]5522;`).
84
+ // - DCS: DECRQSS/XTGETTCAP `\x1bP1$r` / `\x1bP1+r`, XTVERSION `\x1bP>|`, DA3 `\x1bP!|`.
85
+ // - APC: kitty graphics `\x1b_G`, TSP `\x1b_tsp;`, Glyph Protocol `\x1b_25a1;`.
86
+ const LEGACY_STRING_REPLY_OPENER =
87
+ /^(?:\x1b\]\d+(?:;|$)|\x1bP(?:\d(?:[$+](?:r|$)|$)|[>!](?:\||$))|\x1b_(?:G|t(?:s(?:p(?:;|$)|$)|$)|2(?:5(?:a(?:1(?:;|$)|$)|$)|$)))/;
72
88
 
73
89
  // SGR mouse report bodies live between `<` and the terminating `M`/`m`.
74
90
  // Matched only when the trailing byte is a valid terminator, so the regex
@@ -108,6 +124,74 @@ function isRawMultilineBurst(text: string): boolean {
108
124
  return false;
109
125
  }
110
126
 
127
+ // `resolveEscapeEnd` result for an abandoned string sequence; see there.
128
+ const STRING_ABORTED = -3;
129
+ const OSC5522_PREFIX = "\x1b]5522;";
130
+ // Kitty clipboard metadata (`type=read:status=DATA:mime=…:pw=…:id=…`) is
131
+ // short; bounding the `;` search keeps a malformed packet O(n).
132
+ const MAX_OSC5522_METADATA = 1024;
133
+
134
+ /**
135
+ * Resolve an OSC/DCS/APC string sequence by its grammar, not by time. A
136
+ * string ends at ST (`ESC \`), or BEL for OSC. Its payload never contains
137
+ * another control byte or escape, and an OSC 5522 payload (after the
138
+ * metadata `;`) is strict base64. So a slow terminal — a clipboard owner
139
+ * serving a paste lazily — just extends the wait, while a keypress that
140
+ * cannot be payload (Enter, Backspace, Esc, arrows, space) proves the
141
+ * terminal abandoned the string: the caller drops the head and parses that
142
+ * key normally. Neither half of a stalled packet is ever typed as text.
143
+ *
144
+ * The scan is bounded to `[searchFrom, scanLimit)`; `resumeSearchFrom - 1`
145
+ * keeps a one-byte overlap so an `ESC \` split across chunks is still found.
146
+ */
147
+ function resolveStringEnd(
148
+ buffer: string,
149
+ pos: number,
150
+ length: number,
151
+ resumeSearchFrom: number,
152
+ belTerminates: boolean,
153
+ ): number {
154
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
155
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
156
+ const base64From = belTerminates ? osc5522PayloadStart(buffer, pos, scanLimit) : -1;
157
+ // ECMA-48 §8.3.27: a DCS command string may carry BS..CR (0x08-0x0D).
158
+ const dcs = buffer.charCodeAt(pos + 1) === 0x50; /* P */
159
+ for (let i = searchFrom; i < scanLimit; i++) {
160
+ const code = buffer.charCodeAt(i);
161
+ if (code === 0x1b /* ESC */) {
162
+ // A lone trailing ESC may open ST; re-examined next call.
163
+ if (i + 1 === scanLimit) break;
164
+ if (buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
165
+ return STRING_ABORTED - i;
166
+ }
167
+ if (code === 0x07 /* BEL */ && belTerminates) return i + 1;
168
+ if ((code < 0x20 || code === 0x7f) && !(dcs && code >= 0x08 && code <= 0x0d)) return STRING_ABORTED - i;
169
+ if (base64From !== -1 && i >= base64From && !isBase64Code(code)) return STRING_ABORTED - i;
170
+ }
171
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
172
+ }
173
+
174
+ /** Index where an OSC 5522 packet's base64 payload starts, or -1 if not (yet) known. */
175
+ function osc5522PayloadStart(buffer: string, pos: number, scanLimit: number): number {
176
+ if (!buffer.startsWith(OSC5522_PREFIX, pos)) return -1;
177
+ const metadataEnd = Math.min(scanLimit, pos + OSC5522_PREFIX.length + MAX_OSC5522_METADATA);
178
+ for (let i = pos + OSC5522_PREFIX.length; i < metadataEnd; i++) {
179
+ if (buffer.charCodeAt(i) === 0x3b /* ; */) return i + 1;
180
+ }
181
+ return -1;
182
+ }
183
+
184
+ function isBase64Code(code: number): boolean {
185
+ return (
186
+ (code >= 0x41 && code <= 0x5a) || // A-Z
187
+ (code >= 0x61 && code <= 0x7a) || // a-z
188
+ (code >= 0x30 && code <= 0x39) || // 0-9
189
+ code === 0x2b || // +
190
+ code === 0x2f || // /
191
+ code === 0x3d // =
192
+ );
193
+ }
194
+
111
195
  /**
112
196
  * Resolve the exclusive-end index of the escape sequence starting at `pos`
113
197
  * (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
@@ -125,6 +209,9 @@ function isRawMultilineBurst(text: string): boolean {
125
209
  * `-2` — incomplete and the prefix already spans the per-type cap;
126
210
  * the caller cap-flushes CSI as raw bytes; string types
127
211
  * (OSC/DCS/APC) are dropped and their tail discarded.
212
+ * `<= STRING_ABORTED` — an OSC/DCS/APC interrupted by a byte its payload
213
+ * cannot contain; `STRING_ABORTED - result` is that byte's
214
+ * index.
128
215
  */
129
216
  function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
130
217
  if (pos + 1 >= length) return -1;
@@ -174,45 +261,13 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
174
261
  }
175
262
  return length - pos >= MAX_CSI_BYTES ? -2 : -1;
176
263
  }
177
- case 0x5d /* ] */: {
178
- // OSC: ESC ] ... BEL or ST (ESC \). Scan is bounded to
179
- // [searchFrom, scanLimit): `String#indexOf` has no end bound, so
180
- // an unterminated payload delivered as one huge chunk would
181
- // otherwise be scanned to the end of the buffer — past the cap
182
- // this function exists to enforce. `resumeSearchFrom - 1` keeps
183
- // the one-byte overlap so an `ESC \` split across chunks is
184
- // still found (the prior call's trailing ESC is re-inspected).
185
- const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
186
- const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
187
- for (let i = searchFrom; i < scanLimit; i++) {
188
- const code = buffer.charCodeAt(i);
189
- if (code === 0x07 /* BEL */) return i + 1;
190
- if (code === 0x1b /* ESC */) {
191
- // `ESC \` (ST) must end within the cap; a lone trailing
192
- // ESC at the buffer edge stays incomplete and is
193
- // re-examined next call via the resume overlap.
194
- if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
195
- }
196
- }
197
- return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
198
- }
264
+ case 0x5d /* ] */:
265
+ // OSC: ESC ] ... BEL or ST (ESC \).
266
+ return resolveStringEnd(buffer, pos, length, resumeSearchFrom, true);
199
267
  case 0x50 /* P */:
200
- case 0x5f /* _ */: {
201
- // DCS / APC: ESC P/_ ... ST (ESC \). Same bounded scan and
202
- // split-ST overlap as the OSC branch, minus BEL.
203
- const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
204
- const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
205
- for (let i = searchFrom; i < scanLimit; i++) {
206
- if (
207
- buffer.charCodeAt(i) === 0x1b /* ESC */ &&
208
- i + 1 < scanLimit &&
209
- buffer.charCodeAt(i + 1) === 0x5c /* \ */
210
- ) {
211
- return i + 2;
212
- }
213
- }
214
- return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
215
- }
268
+ case 0x5f /* _ */:
269
+ // DCS / APC: ESC P/_ ... ST (ESC \).
270
+ return resolveStringEnd(buffer, pos, length, resumeSearchFrom, false);
216
271
  case 0x4f /* O */:
217
272
  // SS3: ESC O + 1 char.
218
273
  return pos + 3 <= length ? pos + 3 : -1;
@@ -236,10 +291,18 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
236
291
  function extractCompleteSequences(
237
292
  buffer: string,
238
293
  resumeSearchFrom: number,
239
- ): { sequences: string[]; remainder: string; resumeSearchFrom: number; discardFrom?: number } {
294
+ ): {
295
+ sequences: string[];
296
+ remainder: string;
297
+ resumeSearchFrom: number;
298
+ discardFrom?: number;
299
+ /** The buffered string head at offset 0 was abandoned (see `resolveStringEnd`). */
300
+ abortedHead?: boolean;
301
+ } {
240
302
  const sequences: string[] = [];
241
303
  const length = buffer.length;
242
304
  let pos = 0;
305
+ let abortedHead = false;
243
306
 
244
307
  // Index-based scanning: this is the input hot path. Slicing the remaining
245
308
  // buffer (or Array.from-ing it) per iteration would make plain-text bursts
@@ -273,7 +336,7 @@ function extractCompleteSequences(
273
336
  // window so cases 1/2 can still arrive; if no follower
274
337
  // arrives, `flush()` splits the held remainder into two ESC
275
338
  // events (#3857).
276
- return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
339
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0, abortedHead };
277
340
  }
278
341
  const third = buffer.charCodeAt(pos + 2);
279
342
  if (third !== 0x5b && third !== 0x4f) {
@@ -292,7 +355,7 @@ function extractCompleteSequences(
292
355
  // text (settings search filling with "[B" or "[<35;22;17M").
293
356
  const innerEnd = resolveEscapeEnd(buffer, pos + 1, length, 0);
294
357
  if (innerEnd === -1) {
295
- return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
358
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0, abortedHead };
296
359
  }
297
360
  if (innerEnd === -2) {
298
361
  // `third` is CSI/SS3 here, never a string type.
@@ -321,13 +384,21 @@ function extractCompleteSequences(
321
384
  // Single ESC — resolve directly. Hint carries over from the previous
322
385
  // call only when we are still on the buffered escape (pos === 0).
323
386
  const end = resolveEscapeEnd(buffer, pos, length, pos === 0 ? hint : 0);
387
+ if (end <= STRING_ABORTED) {
388
+ // The terminal abandoned this string: drop the head (protocol
389
+ // data, never keys) and parse the interrupting byte normally.
390
+ if (pos === 0) abortedHead = true;
391
+ pos = STRING_ABORTED - end;
392
+ hint = 0;
393
+ continue;
394
+ }
324
395
  if (end === -1) {
325
396
  // Buffer for more. When this is the leading OSC/DCS/APC,
326
397
  // remember how far we scanned so the next `process()` call
327
398
  // resumes from there instead of rescanning the whole buffer.
328
399
  const next = pos + 1 < length ? buffer.charCodeAt(pos + 1) : -1;
329
400
  const nextHint = pos === 0 && (next === 0x5d || next === 0x50 || next === 0x5f) ? length : 0;
330
- return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint };
401
+ return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint, abortedHead };
331
402
  }
332
403
  if (end === -2) {
333
404
  const next = buffer.charCodeAt(pos + 1);
@@ -349,7 +420,7 @@ function extractCompleteSequences(
349
420
  hint = 0;
350
421
  }
351
422
 
352
- return { sequences, remainder: "", resumeSearchFrom: 0 };
423
+ return { sequences, remainder: "", resumeSearchFrom: 0, abortedHead };
353
424
  }
354
425
 
355
426
  export type StdinBufferOptions = {
@@ -433,6 +504,20 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
433
504
  #stringDiscardBytes = 0;
434
505
  #stringDiscardEscHeld = false;
435
506
  #stringDiscardWatchdog?: NodeJS.Timeout;
507
+ // Pending string head bookkeeping (see `#trackStringRead`): when the head
508
+ // last made terminal-shaped progress (0 = no head tracked), keys typed
509
+ // while it stalled, and the abandonment timer.
510
+ #stringProgressMs = 0;
511
+ #stringTypedKeys: string[] = [];
512
+ // Buffer offset of each typed key, parallel to `#stringTypedKeys`.
513
+ #stringTypedAt: number[] = [];
514
+ #stringAbandonTimer?: NodeJS.Timeout;
515
+ // An abandoned string head, kept so a late terminal-shaped tail still
516
+ // completes it instead of being typed (see `#resumeDormantString`).
517
+ #dormantString: string | undefined;
518
+ #dormantUntilMs = 0;
519
+ // The pending head was resumed from dormancy; abandoning it again drops it.
520
+ #stringResumed = false;
436
521
 
437
522
  constructor(options: StdinBufferOptions = {}) {
438
523
  super();
@@ -472,6 +557,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
472
557
  str = this.#consumeStringDiscard(str);
473
558
  if (str.length === 0) return;
474
559
  }
560
+ if (this.#dormantString !== undefined && this.#buffer.length === 0) {
561
+ if (Date.now() < this.#dormantUntilMs) this.#resumeDormantString(str);
562
+ else this.#dormantString = undefined;
563
+ }
564
+ if (this.#bufferIsStringHead()) this.#trackStringRead(str);
475
565
 
476
566
  if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
477
567
  // The buffered partial already hit its flush timeout. A new escape is
@@ -528,6 +618,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
528
618
 
529
619
  const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
530
620
  if (startIndex !== -1) {
621
+ // Keys typed while a string head stalled precede the paste.
622
+ this.#emitStringTypedKeys();
531
623
  if (startIndex > 0) {
532
624
  const beforePaste = this.#buffer.slice(0, startIndex);
533
625
  const result = extractCompleteSequences(beforePaste, 0);
@@ -554,6 +646,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
554
646
  const junk = this.#buffer.slice(result.discardFrom);
555
647
  this.#buffer = "";
556
648
  this.#escapeSearchOffset = 0;
649
+ this.#takeStringTypedKeys();
557
650
  for (const sequence of result.sequences) {
558
651
  this.#emitDataSequence(sequence);
559
652
  }
@@ -565,14 +658,26 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
565
658
  this.#buffer = result.remainder;
566
659
  this.#escapeSearchOffset = result.resumeSearchFrom;
567
660
 
661
+ // An abandoned string head hands back the keys typed while it stalled;
662
+ // they precede the byte that aborted it.
663
+ if (result.abortedHead) this.#emitStringTypedKeys();
568
664
  for (const sequence of result.sequences) {
569
665
  this.#emitDataSequence(sequence);
570
666
  }
571
667
 
572
- if (this.#buffer.length > 0) {
573
- this.#armFlushTimer();
574
- } else {
668
+ // A string head waits for its terminator, an aborting byte, or the
669
+ // abandonment bound (`#trackStringRead`), never the key flush window.
670
+ if (this.#bufferIsStringHead()) {
671
+ if (this.#stringProgressMs === 0) {
672
+ this.#dormantString = undefined;
673
+ this.#stringResumed = false;
674
+ this.#noteStringProgress();
675
+ }
575
676
  this.#partialHoldStartMs = 0;
677
+ } else {
678
+ this.#takeStringTypedKeys();
679
+ if (this.#buffer.length > 0) this.#armFlushTimer();
680
+ else this.#partialHoldStartMs = 0;
576
681
  }
577
682
  }
578
683
 
@@ -751,7 +856,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
751
856
  this.#timeout = undefined;
752
857
  this.#flushDeferral = setTimeout(() => {
753
858
  this.#flushDeferral = undefined;
754
- this.#flushExpired(true);
859
+ this.#flushExpired();
755
860
  });
756
861
  }, this.#timeoutMs);
757
862
  }
@@ -798,8 +903,132 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
798
903
  return SGR_MOUSE_PARTIAL.test(this.#buffer) || isKittyProtocolActive();
799
904
  }
800
905
 
906
+ /**
907
+ * Whether the buffer is the head of an OSC/DCS/APC string: terminal
908
+ * protocol data (OSC 5522 clipboard read, graphics reply), never a key.
909
+ * Without the kitty keyboard protocol `\x1b]`/`\x1bP`/`\x1b_` may be
910
+ * Alt+]/Alt+P/Alt+_, alone or batched with the next keys, so a head counts
911
+ * only while it follows the opening of a reply omp actually receives
912
+ * ({@link LEGACY_STRING_REPLY_OPENER}); the moment it diverges it falls
913
+ * back to the key flush timer.
914
+ */
915
+ #bufferIsStringHead(): boolean {
916
+ const buffer = this.#buffer;
917
+ if (!STRING_SEQ_PARTIAL.test(buffer)) return false;
918
+ if (isKittyProtocolActive()) return true;
919
+ return buffer.length > 2 && LEGACY_STRING_REPLY_OPENER.test(buffer);
920
+ }
921
+
922
+ /**
923
+ * Classify a read arriving while a string head is pending. Chunk boundaries
924
+ * do not follow terminal writes (SSH, tmux), so no read is ever torn out of
925
+ * the string: every byte stays payload until the terminator completes it or
926
+ * an impossible byte aborts it, and every read refreshes the abandonment
927
+ * bound so a slow transfer is never abandoned while it still streams.
928
+ *
929
+ * Typing and terminal payload are indistinguishable by their bytes; when in
930
+ * doubt the string wins, because typed base64 is the failure this exists
931
+ * to prevent. Only a lone printable key arriving after the flush window
932
+ * passed without a read — the shape of typing — is remembered: a head that
933
+ * completes keeps it as payload, one that is abandoned (aborting byte, or
934
+ * {@link STRING_ABANDON_MS} without reads) replays it. Multi-byte reads,
935
+ * even keys an event-loop stall batched together, stay protocol data.
936
+ */
937
+ #trackStringRead(str: string): void {
938
+ const stalled = Date.now() - this.#stringProgressMs >= this.#timeoutMs;
939
+ this.#noteStringProgress();
940
+ const code = str.codePointAt(0)!;
941
+ if (!stalled || str.length !== (code > 0xffff ? 2 : 1) || code < 0x20 || code === 0x7f) return;
942
+ // Only a key the string would keep as payload: a terminator completes
943
+ // it, and an impossible byte aborts it and is delivered by the parser.
944
+ const candidate = this.#buffer + str;
945
+ const end = resolveStringEnd(
946
+ candidate,
947
+ 0,
948
+ candidate.length,
949
+ this.#buffer.length,
950
+ this.#buffer.charCodeAt(1) === 0x5d,
951
+ );
952
+ if (end !== -1) return;
953
+ this.#stringTypedKeys.push(str);
954
+ this.#stringTypedAt.push(this.#buffer.length);
955
+ }
956
+
957
+ #noteStringProgress(): void {
958
+ this.#stringProgressMs = Date.now();
959
+ clearTimeout(this.#stringAbandonTimer);
960
+ this.#stringAbandonTimer = setTimeout(() => {
961
+ this.#stringAbandonTimer = undefined;
962
+ if (!this.#bufferIsStringHead()) {
963
+ this.#takeStringTypedKeys();
964
+ return;
965
+ }
966
+ // The replayed keys are not payload: cut them from the dormant head
967
+ // so a late tail completes the packet the terminal actually sent.
968
+ let dormant = this.#buffer;
969
+ for (let i = this.#stringTypedAt.length - 1; i >= 0; i--) {
970
+ const at = this.#stringTypedAt[i]!;
971
+ dormant = dormant.slice(0, at) + dormant.slice(at + this.#stringTypedKeys[i]!.length);
972
+ }
973
+ if (this.#stringResumed) {
974
+ this.#dormantString = undefined;
975
+ } else {
976
+ this.#dormantString = dormant;
977
+ this.#dormantUntilMs = Date.now() + STRING_DORMANT_MS;
978
+ }
979
+ this.#buffer = "";
980
+ this.#escapeSearchOffset = 0;
981
+ this.#emitStringTypedKeys();
982
+ }, STRING_ABANDON_MS);
983
+ }
984
+
985
+ /** End string head tracking; returns the keys typed while it stalled. */
986
+ #takeStringTypedKeys(): string[] {
987
+ if (this.#stringProgressMs === 0) return [];
988
+ const keys = this.#stringTypedKeys;
989
+ this.#stringTypedKeys = [];
990
+ this.#stringTypedAt = [];
991
+ this.#stringProgressMs = 0;
992
+ clearTimeout(this.#stringAbandonTimer);
993
+ this.#stringAbandonTimer = undefined;
994
+ return keys;
995
+ }
996
+
997
+ /** End string head tracking and replay the keys typed while it stalled. */
998
+ #emitStringTypedKeys(): void {
999
+ for (const keys of this.#takeStringTypedKeys()) {
1000
+ for (const sequence of extractCompleteSequences(keys, 0).sequences) this.#emitDataSequence(sequence);
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Re-attach a late tail to an abandoned string head. A read that completes
1006
+ * the head (even a lone BEL, or `\` after a held ESC), a lone ESC under the
1007
+ * kitty keyboard protocol (never a key there, so it opens a split ST), or a
1008
+ * multi-byte read that continues its grammar (strict base64 for OSC 5522)
1009
+ * resumes the head, as protocol data like any multi-byte read. Other lone
1010
+ * printable keys pass through as typing and keep the head dormant;
1011
+ * anything else means input moved on.
1012
+ */
1013
+ #resumeDormantString(str: string): void {
1014
+ const head = this.#dormantString!;
1015
+ const candidate = head + str;
1016
+ const end = resolveStringEnd(candidate, 0, candidate.length, head.length, head.charCodeAt(1) === 0x5d);
1017
+ const code = str.codePointAt(0)!;
1018
+ const heldSt = str === ESC && isKittyProtocolActive();
1019
+ if (end <= 0 && !heldSt && str.length === (code > 0xffff ? 2 : 1)) {
1020
+ if (code < 0x20 || code === 0x7f) this.#dormantString = undefined;
1021
+ return;
1022
+ }
1023
+ this.#dormantString = undefined;
1024
+ if (end <= STRING_ABORTED) return;
1025
+ this.#buffer = head;
1026
+ this.#escapeSearchOffset = head.length;
1027
+ this.#stringResumed = true;
1028
+ }
1029
+
801
1030
  /** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
802
- #flushExpired(fromTimer = false): void {
1031
+ #flushExpired(): void {
803
1032
  if (this.#buffer.length === 0) {
804
1033
  this.#partialHoldStartMs = 0;
805
1034
  return;
@@ -812,16 +1041,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
812
1041
  }
813
1042
  }
814
1043
  this.#partialHoldStartMs = 0;
815
- for (const sequence of this.#drainBuffered(fromTimer)) {
1044
+ for (const sequence of this.#drainBuffered()) {
816
1045
  this.#emitDataSequence(sequence);
817
1046
  }
818
1047
  }
819
1048
 
820
1049
  flush(): string[] {
821
- return this.#drainBuffered(false);
1050
+ return this.#drainBuffered();
822
1051
  }
823
1052
 
824
- #drainBuffered(discardTornString: boolean): string[] {
1053
+ #drainBuffered(): string[] {
825
1054
  this.#clearFlushTimer();
826
1055
 
827
1056
  const rawCandidate = this.#takeRawPasteCandidate();
@@ -832,6 +1061,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
832
1061
  return sequences;
833
1062
  }
834
1063
 
1064
+ const stringHead = this.#bufferIsStringHead();
835
1065
  const buffered = this.#buffer;
836
1066
  this.#buffer = "";
837
1067
  this.#escapeSearchOffset = 0;
@@ -843,15 +1073,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
843
1073
  // split in `extractCompleteSequences` and deliver two ESC events.
844
1074
  if (buffered === `${ESC}${ESC}`) {
845
1075
  sequences.push(ESC, ESC);
846
- } else if (isKittyProtocolActive() && STRING_SEQ_PARTIAL.test(buffered)) {
847
- // Torn OSC/DCS/APC head. Under the kitty keyboard protocol no key
848
- // ever arrives as a bare `\x1b]`/`\x1bP`/`\x1b_`, so this is always
849
- // terminal protocol data (kitty OSC 5522 clipboard read, graphics
850
- // reply) whose tail is still in flight. Never deliver the head as a
851
- // key; on a timeout flush also swallow the tail up to its
852
- // terminator so kilobytes of base64 are not typed into the focused
853
- // component (image paste spam).
854
- if (discardTornString) this.#enterStringDiscard();
1076
+ } else if (stringHead) {
1077
+ // Unterminated OSC/DCS/APC head: protocol data, never delivered as a
1078
+ // key. Its tracking ends with it so the next head starts fresh.
1079
+ this.#takeStringTypedKeys();
855
1080
  } else {
856
1081
  sequences.push(buffered);
857
1082
  }
@@ -930,6 +1155,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
930
1155
  this.#clearPasteWatchdog();
931
1156
  this.#clearRawPasteTimer();
932
1157
  this.#exitStringDiscard();
1158
+ this.#takeStringTypedKeys();
1159
+ this.#dormantString = undefined;
1160
+ this.#stringResumed = false;
933
1161
  this.#buffer = "";
934
1162
  this.#rawPasteCandidate = "";
935
1163
  this.#pasteMode = false;
package/src/terminal.ts CHANGED
@@ -856,6 +856,8 @@ export class ProcessTerminal implements Terminal {
856
856
  // Releases a relayed lone ESC the decoder held for a sequence tail that never came.
857
857
  #win32PendingFlushTimer?: Timer;
858
858
  #stdinBuffer?: StdinBuffer;
859
+ /** Parsed input path shared by direct VT and win32-input-mode relayed text. */
860
+ #stdinSequenceHandler?: (sequence: string) => void;
859
861
  #stdinDataHandler?: (data: string) => void;
860
862
  #disconnectHandler?: () => void;
861
863
  #stdinEndHandler = () => {
@@ -1387,7 +1389,30 @@ export class ProcessTerminal implements Terminal {
1387
1389
  // skip the subparameter tail instead of dropping the whole report.
1388
1390
  const inBandResizePattern = /^\x1b\[48;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?t$/;
1389
1391
 
1390
- this.#stdinBuffer.on("data", (sequence: string) => {
1392
+ this.#stdinSequenceHandler = (sequence: string) => {
1393
+ const decoder = this.#win32InputDecoder;
1394
+ const win32Keys = decoder?.decode(sequence);
1395
+ if (decoder && win32Keys !== undefined) {
1396
+ clearTimeout(this.#win32PendingFlushTimer);
1397
+ this.#win32PendingFlushTimer = undefined;
1398
+ // The record is ESC-led, so it interrupts any direct reply still being
1399
+ // reassembled, just as a raw escape does below. Decoded keys carry no ESC
1400
+ // of their own and would otherwise be appended to that fragment.
1401
+ this.#privateCsiResponseBuffer = "";
1402
+ this.#inBandResizeBuffer = "";
1403
+ this.#osc11ResponseBuffer = "";
1404
+ this.#osc99ResponseBuffer = "";
1405
+ this.#glyphProtocolReplyBuffer = "";
1406
+ this.#tspReplyBuffer = "";
1407
+ for (const key of win32Keys) this.#stdinSequenceHandler?.(key);
1408
+ if (decoder.hasPendingSequence) {
1409
+ this.#win32PendingFlushTimer = setTimeout(
1410
+ () => this.#releaseWin32PendingSequence(),
1411
+ WIN32_RELAYED_ESCAPE_TIMEOUT_MS,
1412
+ );
1413
+ }
1414
+ return;
1415
+ }
1391
1416
  // Fast path for plain-text bytes: every escape-probe regex below
1392
1417
  // anchors on `^\x1b…`, so a byte that is not ESC can never match. A
1393
1418
  // non-bracketed paste of N printable chars arrives as N per-scalar
@@ -1719,20 +1744,6 @@ export class ProcessTerminal implements Terminal {
1719
1744
  return;
1720
1745
  }
1721
1746
  if (this.#inputHandler) {
1722
- const decoder = this.#win32InputDecoder;
1723
- const win32Keys = decoder?.decode(sequence);
1724
- if (decoder && win32Keys !== undefined) {
1725
- clearTimeout(this.#win32PendingFlushTimer);
1726
- this.#win32PendingFlushTimer = undefined;
1727
- for (const key of win32Keys) this.#inputHandler(key);
1728
- if (decoder.hasPendingSequence) {
1729
- this.#win32PendingFlushTimer = setTimeout(
1730
- () => this.#releaseWin32PendingSequence(),
1731
- WIN32_RELAYED_ESCAPE_TIMEOUT_MS,
1732
- );
1733
- }
1734
- return;
1735
- }
1736
1747
  // Windows console hosts drop AltGr text under kitty (AltGr+F → `CSI 102;3u`);
1737
1748
  // recover it from the active layout before any keybinding sees an Alt chord.
1738
1749
  const altGrText =
@@ -1741,7 +1752,8 @@ export class ProcessTerminal implements Terminal {
1741
1752
  : undefined;
1742
1753
  this.#inputHandler(altGrText ?? sequence);
1743
1754
  }
1744
- });
1755
+ };
1756
+ this.#stdinBuffer.on("data", this.#stdinSequenceHandler);
1745
1757
 
1746
1758
  // Re-wrap paste content with bracketed paste markers for existing editor
1747
1759
  // handling. An Enter that shared the paste's stdin read rides along so
@@ -2104,7 +2116,7 @@ export class ProcessTerminal implements Terminal {
2104
2116
  clearTimeout(this.#win32PendingFlushTimer);
2105
2117
  this.#win32PendingFlushTimer = undefined;
2106
2118
  const pending = this.#win32InputDecoder?.flushPendingSequence() ?? [];
2107
- for (const key of pending) this.#inputHandler?.(key);
2119
+ for (const key of pending) this.#stdinSequenceHandler?.(key);
2108
2120
  }
2109
2121
 
2110
2122
  #disableWin32InputMode(): void {
@@ -2442,6 +2454,7 @@ export class ProcessTerminal implements Terminal {
2442
2454
  this.#stdinBuffer.destroy();
2443
2455
  this.#stdinBuffer = undefined;
2444
2456
  }
2457
+ this.#stdinSequenceHandler = undefined;
2445
2458
 
2446
2459
  // Remove event handlers
2447
2460
  if (this.#stdinDataHandler) {
@@ -25,6 +25,7 @@ export declare const SEARCH_PROVIDER_LABELS: {
25
25
  readonly tavily: "Tavily";
26
26
  readonly firecrawl: "Firecrawl";
27
27
  readonly brave: "Brave";
28
+ readonly deepseek: "DeepSeek";
28
29
  readonly kimi: "Kimi";
29
30
  readonly synthetic: "Synthetic";
30
31
  readonly ollama: "Ollama";
@@ -26,6 +26,7 @@ export const SEARCH_PROVIDER_LABELS = {
26
26
  tavily: "Tavily",
27
27
  firecrawl: "Firecrawl",
28
28
  brave: "Brave",
29
+ deepseek: "DeepSeek",
29
30
  kimi: "Kimi",
30
31
  synthetic: "Synthetic",
31
32
  ollama: "Ollama",
package/src/tui.ts CHANGED
@@ -110,6 +110,11 @@ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
110
110
  // native text selection.
111
111
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
112
112
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
113
+ // Cell-size replies: CSI 6 ; height ; width t in pixels, and iTerm2's
114
+ // OSC 1337 ; ReportCellSize = height ; width [; scale] ST in points.
115
+ const CELL_SIZE_REPLY = /^\x1b\[6;(\d+);(\d+)t$/;
116
+ const ITERM2_CELL_SIZE_REPLY =
117
+ /^\x1b\]1337;ReportCellSize=(\d+(?:\.\d+)?);(\d+(?:\.\d+)?)(?:;(\d+(?:\.\d+)?))?(?:\x07|\x1b\\)$/;
113
118
 
114
119
  type MouseTrackingState = "off" | "inline" | "full";
115
120
 
@@ -2661,6 +2666,9 @@ export class TUI extends Container {
2661
2666
  // Query terminal for cell size in pixels: CSI 16 t
2662
2667
  // Response format: CSI 6 ; height ; width t
2663
2668
  this.terminal.write("\x1b[16t");
2669
+ // iTerm2 never answers CSI 16 t. Without a size, rows reserved for an image
2670
+ // and crops of a partly scrolled one assume 9x18 cells and land offset.
2671
+ if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) this.terminal.write("\x1b]1337;ReportCellSize\x1b\\");
2664
2672
  }
2665
2673
 
2666
2674
  /**
@@ -3150,14 +3158,19 @@ export class TUI extends Container {
3150
3158
  }
3151
3159
 
3152
3160
  #consumeCellSizeResponse(data: string): boolean {
3153
- // Response format: ESC [ 6 ; height ; width t
3154
- const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
3155
- if (!match) {
3156
- return false;
3161
+ let heightPx: number;
3162
+ let widthPx: number;
3163
+ const pixels = CELL_SIZE_REPLY.exec(data);
3164
+ if (pixels) {
3165
+ heightPx = parseInt(pixels[1], 10);
3166
+ widthPx = parseInt(pixels[2], 10);
3167
+ } else {
3168
+ const points = ITERM2_CELL_SIZE_REPLY.exec(data);
3169
+ if (!points) return false;
3170
+ const scale = points[3] ? Number(points[3]) : 1;
3171
+ heightPx = Math.round(Number(points[1]) * scale);
3172
+ widthPx = Math.round(Number(points[2]) * scale);
3157
3173
  }
3158
-
3159
- const heightPx = parseInt(match[1], 10);
3160
- const widthPx = parseInt(match[2], 10);
3161
3174
  if (heightPx <= 0 || widthPx <= 0) {
3162
3175
  return true;
3163
3176
  }