@oh-my-pi/pi-tui 18.2.3 → 18.2.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.4] - 2026-09-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed inline images disappearing or temporarily blanking when resizing the terminal in kitty and Ghostty.
10
+
5
11
  ## [18.2.3] - 2026-09-17
6
12
 
7
13
  ### Added
@@ -22,10 +22,10 @@ export declare const DEFAULT_MAX_INLINE_IMAGES = 8;
22
22
  * Bounds how many inline images render as live terminal graphics at once.
23
23
  *
24
24
  * Terminal graphics protocols — Kitty especially — keep every transmitted image
25
- * in a per-terminal store and re-draw placements as content scrolls; text-clear
26
- * escapes (`CSI 2 J` / `CSI 3 J`) do not remove them. Unbounded, a session that
27
- * shows many images piles up placements plus store memory and leaves ghosts in
28
- * scrollback.
25
+ * in a per-screen store and re-draw placements as content scrolls; placements
26
+ * that left the viewport survive text-clear escapes (`CSI 2 J`). Unbounded, a
27
+ * session that shows many images piles up placements plus store memory and
28
+ * leaves ghosts in scrollback.
29
29
  *
30
30
  * The budget keeps the most recent `cap` images live and demotes older ones to
31
31
  * their text fallback. Demotion needs a full redraw (so off-screen rows are
@@ -115,9 +115,9 @@ export declare class ImageBudget {
115
115
  takeResetPurgeIds(): readonly number[];
116
116
  /** Image ids to delete from the terminal this frame; clears the pending set. */
117
117
  takePurgeIds(): readonly number[];
118
- /** All image ids believed to be loaded in the terminal store; clears tracking. */
118
+ /** All image ids believed to be loaded in any screen's store; clears tracking. */
119
119
  takeAllTransmittedIds(): readonly number[];
120
- /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
120
+ /** Whether `imageId`'s data still needs to be transmitted to the surface the in-flight pass paints. */
121
121
  shouldTransmit(imageId: number): boolean;
122
122
  /**
123
123
  * Record a direct-placement image's source pixel geometry so the renderer
@@ -170,11 +170,12 @@ export declare class ImageBudget {
170
170
  lastEpoch: number;
171
171
  }>;
172
172
  /**
173
- * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
174
- * repeated call (e.g. a width-change re-render) never re-sends the data.
173
+ * Queue a one-time transmit for `imageId` on the surface the in-flight pass
174
+ * paints. No-op if that surface already holds the data, so a repeated call
175
+ * (e.g. a width-change re-render) never re-sends it.
175
176
  */
176
177
  enqueueTransmit(imageId: number, sequence: string): void;
177
- /** Whether a frame has image data queued but not yet written to the terminal. */
178
+ /** Whether the in-flight pass's surface has image data queued but not yet written. */
178
179
  hasPendingTransmits(): boolean;
179
180
  /**
180
181
  * True when the budget has nothing in flight on either surface: no live images
@@ -183,15 +184,16 @@ export declare class ImageBudget {
183
184
  * observe pass only then — a partial tree walk would under-count display order.
184
185
  */
185
186
  get quiescent(): boolean;
186
- /** Transmit sequences to write before this frame's placements; clears the queue. */
187
+ /** Transmit sequences to write before this frame's placements on its surface; clears that queue. */
187
188
  takeTransmits(): readonly string[];
188
189
  /**
189
- * Drop transmit tracking so every still-live image re-enqueues its data
190
- * (`a=t`) on the next render. Recovers when the terminal dropped the original
191
- * transmit — e.g. Ghostty discarding graphics sent during its post-startup
192
- * window — where a placement-only replay can never bind a Unicode placeholder.
193
- * Pair with a component invalidate + forced repaint so the data and placement
194
- * re-emit together; keeps no base64 in budget state (the transmit-once design).
190
+ * Drop the normal screen's transmit tracking so every still-live image
191
+ * re-enqueues its data (`a=t`) on the next render. Recovers when the terminal
192
+ * dropped the original transmit — e.g. Ghostty discarding graphics sent during
193
+ * its post-startup window — where a placement-only replay can never bind a
194
+ * Unicode placeholder. Pair with a component invalidate + forced repaint so
195
+ * the data and placement re-emit together; keeps no base64 in budget state
196
+ * (the transmit-once design).
195
197
  */
196
198
  forgetTransmitted(): void;
197
199
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.2.3",
4
+ "version": "18.2.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.2.3",
41
- "@oh-my-pi/pi-utils": "18.2.3"
40
+ "@oh-my-pi/pi-natives": "18.2.4",
41
+ "@oh-my-pi/pi-utils": "18.2.4"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -108,10 +108,10 @@ function nextImageIdSeed(): number {
108
108
  * Bounds how many inline images render as live terminal graphics at once.
109
109
  *
110
110
  * Terminal graphics protocols — Kitty especially — keep every transmitted image
111
- * in a per-terminal store and re-draw placements as content scrolls; text-clear
112
- * escapes (`CSI 2 J` / `CSI 3 J`) do not remove them. Unbounded, a session that
113
- * shows many images piles up placements plus store memory and leaves ghosts in
114
- * scrollback.
111
+ * in a per-screen store and re-draw placements as content scrolls; placements
112
+ * that left the viewport survive text-clear escapes (`CSI 2 J`). Unbounded, a
113
+ * session that shows many images piles up placements plus store memory and
114
+ * leaves ghosts in scrollback.
115
115
  *
116
116
  * The budget keeps the most recent `cap` images live and demotes older ones to
117
117
  * their text fallback. Demotion needs a full redraw (so off-screen rows are
@@ -165,10 +165,16 @@ export class ImageBudget {
165
165
  * {@link forgetTransmitted}.
166
166
  */
167
167
  #resetPurgeIds: number[] = [];
168
- /** Image ids whose data is believed to be loaded in the terminal's store. */
169
- #transmitted = new Set<number>();
170
- /** Transmit sequences (full base64) to write once, before this frame's placements. */
171
- #pendingTransmits = new Map<number, string>();
168
+ /**
169
+ * Image ids whose data is believed to be loaded in each screen's store.
170
+ * kitty and Ghostty keep the normal and alternate buffers' graphics apart
171
+ * and wipe the alternate store on every `?1049h`, so data sent while one
172
+ * buffer was active is unknown to the other: a placement there resolves
173
+ * only after its own transmit.
174
+ */
175
+ #transmitted: Record<Surface, Set<number>> = { screen: new Set(), alt: new Set() };
176
+ /** Transmit sequences (full base64) to write once per surface, before that frame's placements. */
177
+ #pendingTransmits: Record<Surface, Map<number, string>> = { screen: new Map(), alt: new Map() };
172
178
  // True while the in-flight pass is a partial/throwaway pass (the
173
179
  // non-multiplexer resize viewport fast path) that walks only the visible
174
180
  // tail, bottom-up. Such a pass cannot derive display order from observe()
@@ -258,6 +264,8 @@ export class ImageBudget {
258
264
  beginAltScreenLifecycle(): void {
259
265
  resetSurfaceSplit(this.#altSplit);
260
266
  this.#liveIds.alt.clear();
267
+ // `?1049h` hands over an empty graphics store as well as a cleared grid.
268
+ this.#transmitted.alt.clear();
261
269
  }
262
270
 
263
271
  /**
@@ -354,9 +362,10 @@ export class ImageBudget {
354
362
  */
355
363
  limitResidentImages(): void {
356
364
  this.#liveIds[this.#surface] = new Set(this.#passIds.filter(id => this.#passShowsLive(id)));
357
- if (this.#cap <= 0 || this.#transmitted.size <= this.#cap) return;
358
- for (const id of this.#transmitted) {
359
- if (this.#transmitted.size <= this.#cap) break;
365
+ const transmitted = this.#transmitted[this.#surface];
366
+ if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
367
+ for (const id of transmitted) {
368
+ if (transmitted.size <= this.#cap) break;
360
369
  this.#retire(id);
361
370
  }
362
371
  }
@@ -384,15 +393,17 @@ export class ImageBudget {
384
393
  }
385
394
 
386
395
  /**
387
- * Drop `imageId` from the terminal's image store: queue its `d=I` (or cancel
388
- * a transmit that never went out) and forget its placement ledger and key.
396
+ * Drop `imageId` from the painted surface's image store: queue its `d=I` (or
397
+ * cancel a transmit that never went out) and, once no surface holds its data,
398
+ * forget its placement ledger and key.
389
399
  *
390
400
  * The single gate on every destruction path. `d=I` removes an image's
391
- * placements everywhere, scrollback included, and a frame diff only rewrites
392
- * rows whose text changed — so a graphic some standing frame still shows
393
- * cannot be repaired once deleted, and must never be a candidate. Refuses
394
- * when the in-flight pass renders the image live, or when the frame on any
395
- * surface this pass is not repainting does. Returns whether it was retired.
401
+ * placements everywhere on its screen, scrollback included, and a frame diff
402
+ * only rewrites rows whose text changed — so a graphic some standing frame
403
+ * still shows cannot be repaired once deleted, and must never be a
404
+ * candidate. Refuses when the in-flight pass renders the image live, or when
405
+ * the frame on any surface this pass is not repainting does. Returns whether
406
+ * it was retired.
396
407
  */
397
408
  #retire(imageId: number): boolean {
398
409
  if (this.#passShowsLive(imageId)) return false;
@@ -401,13 +412,19 @@ export class ImageBudget {
401
412
  }
402
413
  // A transmit queued by a discarded discovery pass never reached the
403
414
  // terminal, so cancel it instead of transmitting then purging.
404
- if (!this.#pendingTransmits.delete(imageId)) this.#purgeIds.push(imageId);
405
- this.#transmitted.delete(imageId);
406
- this.#deletePlacementState(imageId);
415
+ if (!this.#pendingTransmits[this.#surface].delete(imageId)) this.#purgeIds.push(imageId);
416
+ this.#transmitted[this.#surface].delete(imageId);
417
+ if (!this.#isTransmitted(imageId)) this.#deletePlacementState(imageId);
407
418
  this.#forgetKeyForId(imageId);
408
419
  return true;
409
420
  }
410
421
 
422
+ /** Whether any screen's store is believed to hold `imageId`'s data. */
423
+ #isTransmitted(imageId: number): boolean {
424
+ for (const surface of SURFACES) if (this.#transmitted[surface].has(imageId)) return true;
425
+ return false;
426
+ }
427
+
411
428
  /**
412
429
  * Image ids a destructive reset must delete explicitly, alongside its `d=A`.
413
430
  * Emit only from that reset's repaint; clears the queue.
@@ -427,25 +444,28 @@ export class ImageBudget {
427
444
  return ids;
428
445
  }
429
446
 
430
- /** All image ids believed to be loaded in the terminal store; clears tracking. */
447
+ /** All image ids believed to be loaded in any screen's store; clears tracking. */
431
448
  takeAllTransmittedIds(): readonly number[] {
432
- if (this.#transmitted.size === 0) return EMPTY_IDS;
433
- const ids = [...this.#transmitted];
434
- this.#transmitted.clear();
449
+ const ids = new Set<number>();
450
+ for (const surface of SURFACES) {
451
+ for (const id of this.#transmitted[surface]) ids.add(id);
452
+ this.#transmitted[surface].clear();
453
+ this.#pendingTransmits[surface].clear();
454
+ }
455
+ if (ids.size === 0) return EMPTY_IDS;
435
456
  this.#purgeIds = [];
436
457
  this.#resetPurgeIds = [];
437
- this.#pendingTransmits.clear();
438
458
  this.#keyToId.clear();
439
459
  this.#idToKey.clear();
440
460
  this.#placementState.clear();
441
461
  this.#watchedPlacements.clear();
442
462
  for (const surface of SURFACES) this.#liveIds[surface].clear();
443
- return ids;
463
+ return [...ids];
444
464
  }
445
465
 
446
- /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
466
+ /** Whether `imageId`'s data still needs to be transmitted to the surface the in-flight pass paints. */
447
467
  shouldTransmit(imageId: number): boolean {
448
- return !this.#transmitted.has(imageId);
468
+ return !this.#transmitted[this.#surface].has(imageId);
449
469
  }
450
470
 
451
471
  /**
@@ -566,18 +586,20 @@ export class ImageBudget {
566
586
  }
567
587
 
568
588
  /**
569
- * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
570
- * repeated call (e.g. a width-change re-render) never re-sends the data.
589
+ * Queue a one-time transmit for `imageId` on the surface the in-flight pass
590
+ * paints. No-op if that surface already holds the data, so a repeated call
591
+ * (e.g. a width-change re-render) never re-sends it.
571
592
  */
572
593
  enqueueTransmit(imageId: number, sequence: string): void {
573
- if (this.#transmitted.has(imageId)) return;
574
- this.#transmitted.add(imageId);
575
- this.#pendingTransmits.set(imageId, sequence);
594
+ const transmitted = this.#transmitted[this.#surface];
595
+ if (transmitted.has(imageId)) return;
596
+ transmitted.add(imageId);
597
+ this.#pendingTransmits[this.#surface].set(imageId, sequence);
576
598
  }
577
599
 
578
- /** Whether a frame has image data queued but not yet written to the terminal. */
600
+ /** Whether the in-flight pass's surface has image data queued but not yet written. */
579
601
  hasPendingTransmits(): boolean {
580
- return this.#pendingTransmits.size > 0;
602
+ return this.#pendingTransmits[this.#surface].size > 0;
581
603
  }
582
604
 
583
605
  /**
@@ -587,33 +609,38 @@ export class ImageBudget {
587
609
  * observe pass only then — a partial tree walk would under-count display order.
588
610
  */
589
611
  get quiescent(): boolean {
590
- if (this.#pendingTransmits.size > 0 || this.#purgeIds.length > 0) return false;
612
+ if (this.#purgeIds.length > 0) return false;
613
+ for (const surface of SURFACES) if (this.#pendingTransmits[surface].size > 0) return false;
591
614
  for (const split of [this.#screenSplit, this.#altSplit]) {
592
615
  if (split.lastTotal !== 0 || split.planned !== split.onTerminal) return false;
593
616
  }
594
617
  return true;
595
618
  }
596
619
 
597
- /** Transmit sequences to write before this frame's placements; clears the queue. */
620
+ /** Transmit sequences to write before this frame's placements on its surface; clears that queue. */
598
621
  takeTransmits(): readonly string[] {
599
- if (this.#pendingTransmits.size === 0) return EMPTY_TRANSMITS;
600
- const sequences = [...this.#pendingTransmits.values()];
601
- this.#pendingTransmits.clear();
622
+ const pending = this.#pendingTransmits[this.#surface];
623
+ if (pending.size === 0) return EMPTY_TRANSMITS;
624
+ const sequences = [...pending.values()];
625
+ pending.clear();
602
626
  return sequences;
603
627
  }
604
628
 
605
629
  /**
606
- * Drop transmit tracking so every still-live image re-enqueues its data
607
- * (`a=t`) on the next render. Recovers when the terminal dropped the original
608
- * transmit — e.g. Ghostty discarding graphics sent during its post-startup
609
- * window — where a placement-only replay can never bind a Unicode placeholder.
610
- * Pair with a component invalidate + forced repaint so the data and placement
611
- * re-emit together; keeps no base64 in budget state (the transmit-once design).
630
+ * Drop the normal screen's transmit tracking so every still-live image
631
+ * re-enqueues its data (`a=t`) on the next render. Recovers when the terminal
632
+ * dropped the original transmit — e.g. Ghostty discarding graphics sent during
633
+ * its post-startup window — where a placement-only replay can never bind a
634
+ * Unicode placeholder. Pair with a component invalidate + forced repaint so
635
+ * the data and placement re-emit together; keeps no base64 in budget state
636
+ * (the transmit-once design).
612
637
  */
613
638
  forgetTransmitted(): void {
614
- if (this.#transmitted.size === 0 && this.#pendingTransmits.size === 0) return;
615
- for (const id of this.#transmitted) {
616
- if (!this.#pendingTransmits.has(id)) this.#resetPurgeIds.push(id);
639
+ const transmitted = this.#transmitted.screen;
640
+ const pending = this.#pendingTransmits.screen;
641
+ if (transmitted.size === 0 && pending.size === 0) return;
642
+ for (const id of transmitted) {
643
+ if (!pending.has(id)) this.#resetPurgeIds.push(id);
617
644
  }
618
645
  // The ids go to #resetPurgeIds, drained only by the destructive repaint
619
646
  // itself — never to #purgeIds, which any frame drains. That is how a
@@ -624,8 +651,8 @@ export class ImageBudget {
624
651
  // placements from it, and erasing placeholder text does not remove the
625
652
  // prototype either. Forgetting drops the id from tracking, so without an
626
653
  // explicit `d=I` no later sweep can ever find that placement again.
627
- this.#transmitted.clear();
628
- this.#pendingTransmits.clear();
654
+ transmitted.clear();
655
+ pending.clear();
629
656
  }
630
657
 
631
658
  /**
@@ -641,7 +668,7 @@ export class ImageBudget {
641
668
  * deleting every placement it ever made including scrollback copies.
642
669
  */
643
670
  #forgetKeyForId(id: number): void {
644
- if (this.#transmitted.has(id)) return;
671
+ if (this.#isTransmitted(id)) return;
645
672
  const key = this.#idToKey.get(id);
646
673
  if (key === undefined) return;
647
674
  this.#idToKey.delete(id);
package/src/tui.ts CHANGED
@@ -2773,9 +2773,10 @@ export class TUI extends Container {
2773
2773
  const pendingAltExit = this.#pendingAltExit;
2774
2774
  let buffer = this.#paintBeginSequence + pendingAltExit;
2775
2775
  if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2776
- // ED2/ED3 erase text cells but leave Kitty graphics visible. A reset is
2777
- // explicitly destructive, so remove every placement—not only the ones
2778
- // this TUI tracked—then resend images composed for the clean replay.
2776
+ // A reset is explicitly destructive, so remove every placement—not only
2777
+ // the ones this TUI tracked—then resend images composed for the clean
2778
+ // replay. ED2 below reclaims the rest, but only on terminals that treat
2779
+ // an erase as a graphics clear; the explicit delete covers the others.
2779
2780
  buffer += encodeKittyDeleteAllImages();
2780
2781
  // `d=A` spares virtual placements, and erasing the placeholder text it
2781
2782
  // leaves behind does not remove the prototype either. The ids this
@@ -2789,7 +2790,6 @@ export class TUI extends Container {
2789
2790
  } else {
2790
2791
  this.#imageBudget.takePurgeIds();
2791
2792
  }
2792
- for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
2793
2793
  // ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
2794
2794
  // into pane history (so cleared content stays reachable), so erasing
2795
2795
  // history first would let ED2 refill it with a copy of the old screen —
@@ -2797,7 +2797,16 @@ export class TUI extends Container {
2797
2797
  // ED2-then-ED3 clears the screen, then wipes history including that
2798
2798
  // push. On xterm-family terminals the two erases are independent and
2799
2799
  // the order is irrelevant.
2800
+ //
2801
+ // Both erases MUST precede the image transmits. kitty and Ghostty treat
2802
+ // ED2 as a graphics clear that also frees every image left without a
2803
+ // placement — which is exactly what freshly transmitted data is until
2804
+ // the row carrying its placement is written. Transmitting first let the
2805
+ // erase reclaim the data, so the replay's placements then referenced an
2806
+ // image the terminal no longer had and every inline image vanished
2807
+ // after a settled width resize.
2800
2808
  if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
2809
+ for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
2801
2810
  const diffable =
2802
2811
  geometryStable &&
2803
2812
  historyRows.length === 0 &&