@oh-my-pi/pi-tui 17.4.2 → 18.0.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.
@@ -27,6 +27,8 @@ export interface SelectItem {
27
27
  value: string;
28
28
  label: string;
29
29
  description?: string;
30
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
31
+ icon?: string;
30
32
  /** Dim hint text shown inline after cursor when this item is selected */
31
33
  hint?: string;
32
34
  }
@@ -38,6 +40,8 @@ export interface SelectListTheme {
38
40
  scrollInfo: (text: string) => string;
39
41
  noMatch: (text: string) => string;
40
42
  symbols: SymbolTheme;
43
+ /** Style for the type-icon column on unselected rows. Defaults to plain text. */
44
+ icon?: (text: string) => string;
41
45
  /** Hover band applied to the full row under the mouse pointer. */
42
46
  hovered?: (text: string) => string;
43
47
  }
@@ -64,12 +68,18 @@ export interface SelectListLayoutOptions {
64
68
  * wrap unevenly.
65
69
  */
66
70
  wrapDescription?: boolean;
71
+ /**
72
+ * Cap wrapped descriptions at this many visual rows; the last kept row is
73
+ * ellipsized. Only meaningful with `wrapDescription`.
74
+ */
75
+ maxDescriptionRows?: number;
67
76
  }
68
77
 
69
78
  type SelectItemLayout =
70
79
  | {
71
80
  kind: "description";
72
81
  prefix: string;
82
+ iconCell: string;
73
83
  truncatedValue: string;
74
84
  spacing: string;
75
85
  descriptionSingleLine: string;
@@ -79,6 +89,7 @@ type SelectItemLayout =
79
89
  | {
80
90
  kind: "primary";
81
91
  prefix: string;
92
+ iconCell: string;
82
93
  truncatedValue: string;
83
94
  spacing: "";
84
95
  };
@@ -172,6 +183,7 @@ export class SelectList implements Component, MouseRoutable {
172
183
  }
173
184
 
174
185
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
186
+ const iconColumnWidth = this.#getIconColumnWidth();
175
187
  const wrapEnabled = this.layout.wrapDescription === true;
176
188
  // `maxVisible` is the picker's visual row budget. For non-wrap layouts
177
189
  // every item is one row, so the budget matches the original item count.
@@ -190,7 +202,9 @@ export class SelectList implements Component, MouseRoutable {
190
202
  rowCounts[i] = 0;
191
203
  continue;
192
204
  }
193
- rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
205
+ rowCounts[i] = wrapEnabled
206
+ ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth, iconColumnWidth)
207
+ : 1;
194
208
  visualTotal += rowCounts[i];
195
209
  }
196
210
 
@@ -211,7 +225,13 @@ export class SelectList implements Component, MouseRoutable {
211
225
  const item = this.#filteredItems[i];
212
226
  if (!item) continue;
213
227
  const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex;
214
- const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
228
+ const itemRows = this.#renderItem(
229
+ item,
230
+ i === this.#selectedIndex,
231
+ rowWidth,
232
+ primaryColumnWidth,
233
+ iconColumnWidth,
234
+ );
215
235
  for (const row of itemRows) {
216
236
  if (rows.length >= visualBudget) break;
217
237
  this.#hitRows[rows.length] = i;
@@ -280,25 +300,33 @@ export class SelectList implements Component, MouseRoutable {
280
300
  }
281
301
  }
282
302
 
283
- #renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
284
- const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
303
+ #renderItem(
304
+ item: SelectItem,
305
+ isSelected: boolean,
306
+ width: number,
307
+ primaryColumnWidth: number,
308
+ iconColumnWidth: number,
309
+ ): string[] {
310
+ const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth, iconColumnWidth);
285
311
  const { prefix, truncatedValue, spacing } = layout;
312
+ const iconCell =
313
+ layout.iconCell && !isSelected && this.theme.icon ? this.theme.icon(layout.iconCell) : layout.iconCell;
286
314
 
287
315
  if (layout.kind === "description") {
288
316
  const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
289
317
  if (this.layout.wrapDescription) {
290
- const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
318
+ const wrapped = this.#wrapDescription(descriptionSingleLine, remainingWidth);
291
319
  if (wrapped.length === 0) wrapped.push("");
292
320
  const indent = padding(descriptionStart);
293
321
  const first = wrapped[0] ?? "";
294
322
  if (isSelected) {
295
- const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
323
+ const rows = [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${first}`)];
296
324
  for (let i = 1; i < wrapped.length; i++) {
297
325
  rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
298
326
  }
299
327
  return rows;
300
328
  }
301
- const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
329
+ const rows = [prefix + iconCell + truncatedValue + this.theme.description(spacing + first)];
302
330
  for (let i = 1; i < wrapped.length; i++) {
303
331
  rows.push(this.theme.description(`${indent}${wrapped[i]}`));
304
332
  }
@@ -307,25 +335,34 @@ export class SelectList implements Component, MouseRoutable {
307
335
 
308
336
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
309
337
  if (isSelected) {
310
- return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
338
+ return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${truncatedDesc}`)];
311
339
  }
312
- return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
340
+ return [prefix + iconCell + truncatedValue + this.theme.description(spacing + truncatedDesc)];
313
341
  }
314
342
 
315
343
  if (isSelected) {
316
- return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
344
+ return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}`)];
317
345
  }
318
- return [prefix + truncatedValue];
346
+ return [prefix + iconCell + truncatedValue];
319
347
  }
320
348
 
321
- #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
349
+ #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number, iconColumnWidth: number): number {
322
350
  // Selection style does not change row count; pass isSelected=false to
323
351
  // keep the cheap path uniform for items outside the visible window.
324
- const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
352
+ const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth, iconColumnWidth);
325
353
  if (layout.kind !== "description") return 1;
326
- const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
354
+ const wrapped = this.#wrapDescription(layout.descriptionSingleLine, layout.remainingWidth);
327
355
  return Math.max(1, wrapped.length);
328
356
  }
357
+ /** Wrap a description, capping it at `maxDescriptionRows` with a trailing ellipsis. */
358
+ #wrapDescription(description: string, width: number): string[] {
359
+ const wrapped = wrapTextWithAnsi(description, width);
360
+ const cap = this.layout.maxDescriptionRows;
361
+ if (cap === undefined || cap < 1 || wrapped.length <= cap) return wrapped;
362
+ const kept = wrapped.slice(0, cap);
363
+ kept[cap - 1] = truncateToWidth(`${kept[cap - 1]} …`, width, Ellipsis.Unicode);
364
+ return kept;
365
+ }
329
366
 
330
367
  /**
331
368
  * Pick a contiguous window of items containing `selectedIndex` such that
@@ -380,10 +417,15 @@ export class SelectList implements Component, MouseRoutable {
380
417
  isSelected: boolean,
381
418
  width: number,
382
419
  primaryColumnWidth: number,
420
+ iconColumnWidth: number,
383
421
  ): SelectItemLayout {
384
422
  const cursor = this.theme.symbols?.cursor ?? DEFAULT_CURSOR_SYMBOL;
385
423
  const prefix = isSelected ? `${cursor} ` : padding(visibleWidth(cursor) + 1);
386
- const prefixWidth = visibleWidth(prefix);
424
+ // Icon column: every row reserves the same width so labels stay aligned
425
+ // whether or not an individual item carries an icon.
426
+ const iconWidth = item.icon ? visibleWidth(item.icon) : 0;
427
+ const iconCell = iconColumnWidth > 0 ? (item.icon ?? "") + padding(iconColumnWidth - iconWidth + 1) : "";
428
+ const prefixWidth = visibleWidth(prefix) + (iconColumnWidth > 0 ? iconColumnWidth + 1 : 0);
387
429
  const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
388
430
 
389
431
  if (descriptionSingleLine && width > 40) {
@@ -399,6 +441,7 @@ export class SelectList implements Component, MouseRoutable {
399
441
  return {
400
442
  kind: "description",
401
443
  prefix,
444
+ iconCell,
402
445
  truncatedValue,
403
446
  spacing,
404
447
  descriptionSingleLine,
@@ -413,11 +456,20 @@ export class SelectList implements Component, MouseRoutable {
413
456
  return {
414
457
  kind: "primary",
415
458
  prefix,
459
+ iconCell,
416
460
  truncatedValue,
417
461
  spacing: "",
418
462
  };
419
463
  }
420
464
 
465
+ #getIconColumnWidth(): number {
466
+ let widest = 0;
467
+ for (const item of this.#filteredItems) {
468
+ if (item.icon) widest = Math.max(widest, visibleWidth(item.icon));
469
+ }
470
+ return widest;
471
+ }
472
+
421
473
  #getPrimaryColumnWidth(): number {
422
474
  const { min, max } = this.#getPrimaryColumnBounds();
423
475
  const widestPrimary = this.#filteredItems.reduce((widest, item) => {
@@ -25,14 +25,11 @@ export class Text implements Component {
25
25
  #paddingY: number; // Top/bottom padding
26
26
  #customBgFn?: (text: string) => string;
27
27
  #styleFn?: (text: string) => string;
28
- #widthEpochRevision = 0;
29
-
30
28
  #ignoreTight = false;
31
29
 
32
30
  setIgnoreTight(ignore: boolean): this {
33
31
  if (this.#ignoreTight === ignore) return this;
34
32
  this.#ignoreTight = ignore;
35
- this.#widthEpochRevision++;
36
33
  this.invalidate();
37
34
  return this;
38
35
  }
@@ -40,7 +37,7 @@ export class Text implements Component {
40
37
  // Cache for rendered output
41
38
  #cachedText?: string;
42
39
  #cachedWidth?: number;
43
- #cachedWidthEpoch?: number;
40
+ #cachedWidthConfigEpoch?: number;
44
41
  #cachedLines?: string[];
45
42
 
46
43
  constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
@@ -61,23 +58,17 @@ export class Text implements Component {
61
58
  this.#text = text;
62
59
  this.#cachedText = undefined;
63
60
  this.#cachedWidth = undefined;
64
- this.#cachedWidthEpoch = undefined;
61
+ this.#cachedWidthConfigEpoch = undefined;
65
62
  this.#cachedLines = undefined;
66
- this.#widthEpochRevision++;
67
63
  return true;
68
64
  }
69
65
 
70
- getNativeScrollbackWidthEpochRevision(): number {
71
- return this.#widthEpochRevision;
72
- }
73
-
74
66
  setCustomBgFn(customBgFn?: (text: string) => string): void {
75
67
  this.#customBgFn = customBgFn;
76
68
  this.#cachedText = undefined;
77
69
  this.#cachedWidth = undefined;
78
- this.#cachedWidthEpoch = undefined;
70
+ this.#cachedWidthConfigEpoch = undefined;
79
71
  this.#cachedLines = undefined;
80
- this.#widthEpochRevision++;
81
72
  }
82
73
 
83
74
  /**
@@ -90,16 +81,15 @@ export class Text implements Component {
90
81
  this.#styleFn = styleFn;
91
82
  this.#cachedText = undefined;
92
83
  this.#cachedWidth = undefined;
93
- this.#cachedWidthEpoch = undefined;
84
+ this.#cachedWidthConfigEpoch = undefined;
94
85
  this.#cachedLines = undefined;
95
- this.#widthEpochRevision++;
96
86
  return this;
97
87
  }
98
88
 
99
89
  invalidate(): void {
100
90
  this.#cachedText = undefined;
101
91
  this.#cachedWidth = undefined;
102
- this.#cachedWidthEpoch = undefined;
92
+ this.#cachedWidthConfigEpoch = undefined;
103
93
  this.#cachedLines = undefined;
104
94
  }
105
95
 
@@ -109,7 +99,7 @@ export class Text implements Component {
109
99
  this.#cachedLines &&
110
100
  this.#cachedText === this.#text &&
111
101
  this.#cachedWidth === width &&
112
- this.#cachedWidthEpoch === getWidthConfigEpoch()
102
+ this.#cachedWidthConfigEpoch === getWidthConfigEpoch()
113
103
  ) {
114
104
  return this.#cachedLines;
115
105
  }
@@ -119,7 +109,7 @@ export class Text implements Component {
119
109
  const result: string[] = [];
120
110
  this.#cachedText = this.#text;
121
111
  this.#cachedWidth = width;
122
- this.#cachedWidthEpoch = getWidthConfigEpoch();
112
+ this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
123
113
  this.#cachedLines = result;
124
114
  return result;
125
115
  }
@@ -175,7 +165,7 @@ export class Text implements Component {
175
165
  // Update cache
176
166
  this.#cachedText = this.#text;
177
167
  this.#cachedWidth = width;
178
- this.#cachedWidthEpoch = getWidthConfigEpoch();
168
+ this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
179
169
  this.#cachedLines = result;
180
170
 
181
171
  return result.length > 0 ? result : [""];
package/src/fuzzy.ts CHANGED
@@ -42,6 +42,16 @@ const ALPHANUMERIC_SWAP_PENALTY = 5;
42
42
  const COMPACT_PHRASE_BONUS = 1200;
43
43
  const PHRASE_BONUS = 1000;
44
44
 
45
+ /**
46
+ * Shortest needle worth scanning past its leading occurrence for.
47
+ *
48
+ * One or two characters sit mid-word in nearly every candidate — "im" is inside
49
+ * "experimental" — so rescanning that short would hand the word-start bonus to
50
+ * the whole corpus at once and reshuffle the list on the opening keystrokes of a
51
+ * search instead of narrowing it. Shadowing only misleads for real phrases.
52
+ */
53
+ const MIN_SHADOW_RESCAN_LENGTH = 3;
54
+
45
55
  function normalizeForSearch(value: string): string {
46
56
  return value
47
57
  .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
@@ -172,6 +182,53 @@ function isWordBoundaryPhrase(normalized: string, index: number, length: number)
172
182
  return before && after;
173
183
  }
174
184
 
185
+ /**
186
+ * Offset of the first whole-word occurrence of `phrase` in `normalized`, or -1.
187
+ *
188
+ * A bare `indexOf` reports only the leading occurrence, so a qualifying match is
189
+ * dropped whenever an earlier non-qualifying one shadows it — the whole word
190
+ * "image" in "reimage image provider" loses to the "image" inside "reimage".
191
+ * Occurrences are scanned left to right, so the first qualifying hit is also the
192
+ * best-scoring one: the caller's position tiebreak grows with the offset.
193
+ *
194
+ * Only a hit buried inside a word can shadow. A leading hit that already starts
195
+ * a word is an ordinary prefix match — the query is "image" and the text says
196
+ * "images" — and it is scored exactly as before rather than borrowing a
197
+ * whole-word bonus from some later occurrence; `findCompactWordStart` treats its
198
+ * leading word-start hit the same way. The rescan is additionally limited to
199
+ * {@link MIN_SHADOW_RESCAN_LENGTH} and longer needles.
200
+ */
201
+ function findWordBoundaryPhrase(normalized: string, phrase: string): number {
202
+ if (phrase.length === 0) return -1;
203
+ const first = normalized.indexOf(phrase);
204
+ if (first < 0) return -1;
205
+ if (isWordBoundaryPhrase(normalized, first, phrase.length)) return first;
206
+ if (first === 0 || normalized[first - 1] === " ") return -1;
207
+ if (phrase.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
208
+ for (let at = normalized.indexOf(phrase, first + 1); at >= 0; at = normalized.indexOf(phrase, at + 1)) {
209
+ if (isWordBoundaryPhrase(normalized, at, phrase.length)) return at;
210
+ }
211
+ return -1;
212
+ }
213
+
214
+ /**
215
+ * Offset of the first occurrence of `needle` that starts a word in `index.compact`,
216
+ * or -1. Same shadowing hazard, and the same length floor, as
217
+ * {@link findWordBoundaryPhrase}.
218
+ */
219
+ function findCompactWordStart(index: SearchIndex, needle: string): number {
220
+ if (needle.length === 0) return -1;
221
+ const { compact, compactWordStarts } = index;
222
+ const first = compact.indexOf(needle);
223
+ if (first < 0) return -1;
224
+ if (compactWordStarts.has(first)) return first;
225
+ if (needle.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
226
+ for (let at = compact.indexOf(needle, first + 1); at >= 0; at = compact.indexOf(needle, at + 1)) {
227
+ if (compactWordStarts.has(at)) return at;
228
+ }
229
+ return -1;
230
+ }
231
+
175
232
  function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
176
233
  if (word.text === token) {
177
234
  return { matches: true, score: withPosition(-200, word.index) };
@@ -230,8 +287,8 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
230
287
  if (token.length === 0) return { matches: true, score: 0 };
231
288
 
232
289
  let best: FuzzyMatch | null = null;
233
- const compactIndex = index.compact.indexOf(token);
234
- if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
290
+ const compactIndex = findCompactWordStart(index, token);
291
+ if (compactIndex >= 0) {
235
292
  best = { matches: true, score: withPosition(-140, compactIndex) };
236
293
  }
237
294
 
@@ -290,14 +347,14 @@ function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatc
290
347
  }
291
348
 
292
349
  let totalScore = 0;
293
- const phraseIndex = index.normalized.indexOf(pq.normalized);
294
- if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, pq.normalized.length)) {
350
+ const phraseIndex = findWordBoundaryPhrase(index.normalized, pq.normalized);
351
+ if (phraseIndex >= 0) {
295
352
  totalScore -= PHRASE_BONUS;
296
353
  totalScore += phraseIndex * 0.01;
297
354
  }
298
355
 
299
- const compactPhraseIndex = index.compact.indexOf(pq.compact);
300
- if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
356
+ const compactPhraseIndex = findCompactWordStart(index, pq.compact);
357
+ if (compactPhraseIndex >= 0) {
301
358
  totalScore -= COMPACT_PHRASE_BONUS;
302
359
  totalScore += compactPhraseIndex * 0.01;
303
360
  }
@@ -27,6 +27,7 @@ export interface Keybindings {
27
27
  "tui.editor.yank": true;
28
28
  "tui.editor.yankPop": true;
29
29
  "tui.editor.undo": true;
30
+ "tui.editor.spellingSuggestions": true;
30
31
  // Generic input actions
31
32
  "tui.input.newLine": true;
32
33
  "tui.input.submit": true;
@@ -118,6 +119,10 @@ export const TUI_KEYBINDINGS = {
118
119
  "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
119
120
  "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
120
121
  "tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
122
+ "tui.editor.spellingSuggestions": {
123
+ defaultKeys: "ctrl+.",
124
+ description: "Show spelling replacements",
125
+ },
121
126
  "tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
122
127
  "tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
123
128
  "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
@@ -56,6 +56,17 @@ const PARTIAL_HOLD_MAX_MS = 150;
56
56
  // so the string-terminator cap is generous.
57
57
  const MAX_CSI_BYTES = 4096;
58
58
  const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
59
+ // Torn-string discard bounds. When a capped or timeout-expired OSC/DCS/APC
60
+ // partial is dropped, the bytes that follow are mid-string payload (base64
61
+ // clipboard data, Sixel, …) up to the next ST/BEL. Replaying them as
62
+ // keystrokes types kilobytes of garbage into the focused component (kitty
63
+ // OSC 5522 image paste spam), so discard mode swallows the tail until a
64
+ // terminator — bounded by a byte budget for a terminator-less stream and an
65
+ // inactivity watchdog for a stream that simply stops.
66
+ const STRING_DISCARD_MAX_BYTES = 2 * MAX_STRING_SEQ_BYTES;
67
+ const STRING_DISCARD_INACTIVITY_MS = 1000;
68
+ // Partial that can only be the head of an OSC/DCS/APC string sequence.
69
+ const STRING_SEQ_PARTIAL = /^\x1b[\]P_]/;
59
70
 
60
71
  // SGR mouse report bodies live between `<` and the terminating `M`/`m`.
61
72
  // Matched only when the trailing byte is a valid terminator, so the regex
@@ -110,7 +121,8 @@ function isRawMultilineBurst(text: string): boolean {
110
121
  * `end > pos` — complete sequence, exclusive end index.
111
122
  * `-1` — incomplete, still under the per-type cap; buffer for more.
112
123
  * `-2` — incomplete and the prefix already spans the per-type cap;
113
- * the caller flushes it as raw bytes to guarantee progress.
124
+ * the caller cap-flushes CSI as raw bytes; string types
125
+ * (OSC/DCS/APC) are dropped and their tail discarded.
114
126
  */
115
127
  function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
116
128
  if (pos + 1 >= length) return -1;
@@ -211,17 +223,6 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
211
223
  }
212
224
  }
213
225
 
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
  /**
226
227
  * Split accumulated buffer into complete sequences
227
228
  */
@@ -236,7 +237,7 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
236
237
  function extractCompleteSequences(
237
238
  buffer: string,
238
239
  resumeSearchFrom: number,
239
- ): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
240
+ ): { sequences: string[]; remainder: string; resumeSearchFrom: number; discardFrom?: number } {
240
241
  const sequences: string[] = [];
241
242
  const length = buffer.length;
242
243
  let pos = 0;
@@ -295,8 +296,8 @@ function extractCompleteSequences(
295
296
  return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
296
297
  }
297
298
  if (innerEnd === -2) {
298
- const cap = escapeCapFor(third);
299
- const flushEnd = Math.min(length, pos + cap);
299
+ // `third` is CSI/SS3 here, never a string type.
300
+ const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
300
301
  sequences.push(buffer.slice(pos, flushEnd));
301
302
  pos = flushEnd;
302
303
  hint = 0;
@@ -331,8 +332,14 @@ function extractCompleteSequences(
331
332
  }
332
333
  if (end === -2) {
333
334
  const next = buffer.charCodeAt(pos + 1);
334
- const cap = escapeCapFor(next);
335
- const flushEnd = Math.min(length, pos + cap);
335
+ if (next === 0x5d || next === 0x50 || next === 0x5f) {
336
+ // Unterminated string sequence at the cap (issue #4073 case A):
337
+ // the head is junk and everything after it is mid-string
338
+ // payload. Hand both to the caller's discard mode instead of
339
+ // flushing the payload as per-character typed text.
340
+ return { sequences, remainder: "", resumeSearchFrom: 0, discardFrom: pos };
341
+ }
342
+ const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
336
343
  sequences.push(buffer.slice(pos, flushEnd));
337
344
  pos = flushEnd;
338
345
  hint = 0;
@@ -399,6 +406,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
399
406
  #escapeSearchOffset = 0;
400
407
  #rawPasteCandidate = "";
401
408
  #rawPasteTimer?: NodeJS.Timeout;
409
+ #stringDiscardActive = false;
410
+ #stringDiscardBytes = 0;
411
+ #stringDiscardEscHeld = false;
412
+ #stringDiscardWatchdog?: NodeJS.Timeout;
402
413
 
403
414
  constructor(options: StdinBufferOptions = {}) {
404
415
  super();
@@ -422,6 +433,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
422
433
  } else {
423
434
  str = data;
424
435
  }
436
+ if (this.#stringDiscardActive) {
437
+ // Mid torn-string: swallow payload up to the terminator; only what
438
+ // follows the terminator re-enters normal parsing.
439
+ str = this.#consumeStringDiscard(str);
440
+ if (str.length === 0) return;
441
+ }
425
442
 
426
443
  if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
427
444
  // The buffered partial already hit its flush timeout. A new escape is
@@ -500,6 +517,18 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
500
517
  }
501
518
 
502
519
  const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
520
+ if (result.discardFrom !== undefined) {
521
+ const junk = this.#buffer.slice(result.discardFrom);
522
+ this.#buffer = "";
523
+ this.#escapeSearchOffset = 0;
524
+ for (const sequence of result.sequences) {
525
+ this.#emitDataSequence(sequence);
526
+ }
527
+ this.#enterStringDiscard();
528
+ const after = this.#consumeStringDiscard(junk);
529
+ if (after.length > 0) this.process(after);
530
+ return;
531
+ }
503
532
  this.#buffer = result.remainder;
504
533
  this.#escapeSearchOffset = result.resumeSearchFrom;
505
534
 
@@ -662,7 +691,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
662
691
  this.#timeout = undefined;
663
692
  this.#flushDeferral = setTimeout(() => {
664
693
  this.#flushDeferral = undefined;
665
- this.#flushExpired();
694
+ this.#flushExpired(true);
666
695
  });
667
696
  }, this.#timeoutMs);
668
697
  }
@@ -710,7 +739,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
710
739
  }
711
740
 
712
741
  /** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
713
- #flushExpired(): void {
742
+ #flushExpired(fromTimer = false): void {
714
743
  if (this.#buffer.length === 0) {
715
744
  this.#partialHoldStartMs = 0;
716
745
  return;
@@ -723,12 +752,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
723
752
  }
724
753
  }
725
754
  this.#partialHoldStartMs = 0;
726
- for (const sequence of this.flush()) {
755
+ for (const sequence of this.#drainBuffered(fromTimer)) {
727
756
  this.#emitDataSequence(sequence);
728
757
  }
729
758
  }
730
759
 
731
760
  flush(): string[] {
761
+ return this.#drainBuffered(false);
762
+ }
763
+
764
+ #drainBuffered(discardTornString: boolean): string[] {
732
765
  this.#clearFlushTimer();
733
766
 
734
767
  const rawCandidate = this.#takeRawPasteCandidate();
@@ -750,16 +783,93 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
750
783
  // split in `extractCompleteSequences` and deliver two ESC events.
751
784
  if (buffered === `${ESC}${ESC}`) {
752
785
  sequences.push(ESC, ESC);
786
+ } else if (isKittyProtocolActive() && STRING_SEQ_PARTIAL.test(buffered)) {
787
+ // Torn OSC/DCS/APC head. Under the kitty keyboard protocol no key
788
+ // ever arrives as a bare `\x1b]`/`\x1bP`/`\x1b_`, so this is always
789
+ // terminal protocol data (kitty OSC 5522 clipboard read, graphics
790
+ // reply) whose tail is still in flight. Never deliver the head as a
791
+ // key; on a timeout flush also swallow the tail up to its
792
+ // terminator so kilobytes of base64 are not typed into the focused
793
+ // component (image paste spam).
794
+ if (discardTornString) this.#enterStringDiscard();
753
795
  } else {
754
796
  sequences.push(buffered);
755
797
  }
756
798
  return sequences;
757
799
  }
758
800
 
801
+ /** Enter torn-string discard: swallow mid-string bytes until ST/BEL. */
802
+ #enterStringDiscard(): void {
803
+ this.#stringDiscardActive = true;
804
+ this.#stringDiscardBytes = 0;
805
+ this.#stringDiscardEscHeld = false;
806
+ this.#armStringDiscardWatchdog();
807
+ }
808
+
809
+ #exitStringDiscard(): void {
810
+ this.#stringDiscardActive = false;
811
+ this.#stringDiscardBytes = 0;
812
+ this.#stringDiscardEscHeld = false;
813
+ if (this.#stringDiscardWatchdog) {
814
+ clearTimeout(this.#stringDiscardWatchdog);
815
+ this.#stringDiscardWatchdog = undefined;
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Swallow mid-string bytes; returns the input remaining after the string
821
+ * terminator (empty while still inside the torn string). A trailing ESC is
822
+ * held across calls so an `ESC \` split between reads is still detected.
823
+ * The byte budget bounds a terminator-less stream; the inactivity watchdog
824
+ * recovers a stream that stops without ever terminating.
825
+ */
826
+ #consumeStringDiscard(str: string): string {
827
+ if (this.#stringDiscardEscHeld) {
828
+ this.#stringDiscardEscHeld = false;
829
+ if (str.charCodeAt(0) === 0x5c /* \ */) {
830
+ this.#exitStringDiscard();
831
+ return str.slice(1);
832
+ }
833
+ }
834
+ for (let i = 0; i < str.length; i++) {
835
+ const code = str.charCodeAt(i);
836
+ if (code === 0x07 /* BEL */) {
837
+ this.#exitStringDiscard();
838
+ return str.slice(i + 1);
839
+ }
840
+ if (code === 0x1b /* ESC */) {
841
+ if (i + 1 === str.length) {
842
+ this.#stringDiscardEscHeld = true;
843
+ break;
844
+ }
845
+ if (str.charCodeAt(i + 1) === 0x5c /* \ */) {
846
+ this.#exitStringDiscard();
847
+ return str.slice(i + 2);
848
+ }
849
+ }
850
+ }
851
+ this.#stringDiscardBytes += str.length;
852
+ if (this.#stringDiscardBytes > STRING_DISCARD_MAX_BYTES) {
853
+ this.#exitStringDiscard();
854
+ return "";
855
+ }
856
+ this.#armStringDiscardWatchdog();
857
+ return "";
858
+ }
859
+
860
+ #armStringDiscardWatchdog(): void {
861
+ if (this.#stringDiscardWatchdog) clearTimeout(this.#stringDiscardWatchdog);
862
+ this.#stringDiscardWatchdog = setTimeout(() => {
863
+ this.#stringDiscardWatchdog = undefined;
864
+ this.#exitStringDiscard();
865
+ }, STRING_DISCARD_INACTIVITY_MS);
866
+ }
867
+
759
868
  clear(): void {
760
869
  this.#clearFlushTimer();
761
870
  this.#clearPasteWatchdog();
762
871
  this.#clearRawPasteTimer();
872
+ this.#exitStringDiscard();
763
873
  this.#buffer = "";
764
874
  this.#rawPasteCandidate = "";
765
875
  this.#pasteMode = false;