@oh-my-pi/pi-tui 18.0.0 → 18.0.3

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.
@@ -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
  }
@@ -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;
@@ -105,7 +105,7 @@ export class TerminalInfo {
105
105
  public readonly deccara: boolean = false,
106
106
  readonly supportsScreenToScrollback: boolean = false,
107
107
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
108
- public readonly textSizing: boolean = false,
108
+ public readonly supportsTextSizing: boolean = false,
109
109
  /**
110
110
  * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
111
111
  * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
@@ -557,11 +557,14 @@ export interface RuntimeTerminal extends TerminalInfo {
557
557
  hyperlinks: boolean;
558
558
  deccara: boolean;
559
559
  supportsScreenToScrollback: boolean;
560
+ /** Whether OSC 66 text sizing is currently enabled. */
560
561
  textSizing: boolean;
561
562
  }
562
563
 
563
564
  export const TERMINAL: RuntimeTerminal = (() => {
564
565
  const resolved = getTerminalInfo(TERMINAL_ID).clone();
566
+ // Detection records support; hosts opt into OSC 66 separately.
567
+ resolved.textSizing = false;
565
568
 
566
569
  const forcedImageProtocol = getForcedImageProtocol();
567
570
  if (forcedImageProtocol !== undefined) {
@@ -618,7 +621,7 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
618
621
 
619
622
  /**
620
623
  * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
621
- * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
624
+ * the `tui.textSizing` setting (gated on the terminal's static `supportsTextSizing`
622
625
  * capability); tests flip it directly to exercise the scaled-heading path.
623
626
  */
624
627
  export function setTerminalTextSizing(enabled: boolean): void {
@@ -831,6 +834,14 @@ export function encodeKittyPlacementLine(options: {
831
834
  export function encodeKittyDeleteImage(imageId: number): string {
832
835
  return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
833
836
  }
837
+ /**
838
+ * Delete every Kitty image and placement in the terminal. Used only by an
839
+ * explicit destructive display reset: text erases leave untracked placements
840
+ * painted, so per-image bookkeeping cannot guarantee a clean viewport.
841
+ */
842
+ export function encodeKittyDeleteAllImages(): string {
843
+ return wrapTmuxPassthroughIfNeeded("\x1b_Ga=d,d=A,q=2\x1b\\");
844
+ }
834
845
 
835
846
  /**
836
847
  * Delete a single placement of an image (`d=i`, lowercase): removes its cells
package/src/terminal.ts CHANGED
@@ -231,6 +231,20 @@ function registerPostmortemTerminalRestore(): void {
231
231
  export function setAltScreenActive(active: boolean): void {
232
232
  altScreenActive = active;
233
233
  }
234
+ /**
235
+ * Route an out-of-band escape sequence (e.g. an OSC title update) through the
236
+ * active terminal's output path. While a TUI owns stdout, frame paints go
237
+ * through the off-thread write pump and can split across multiple write(2)
238
+ * calls; a direct main-thread `process.stdout.write` can land between two of
239
+ * them — mid escape sequence — and the host terminal then prints the payload
240
+ * as literal text at the cursor position. Returns false when no terminal has
241
+ * started, in which case the caller owns stdout and may write directly.
242
+ */
243
+ export function writeThroughActiveTerminal(data: string): boolean {
244
+ if (!activeTerminal) return false;
245
+ activeTerminal.write(data);
246
+ return true;
247
+ }
234
248
 
235
249
  const stdoutErrorHandlers = new Set<(err: Error) => void>();
236
250
  let stdoutErrorListenerInstalled = false;