@oh-my-pi/pi-tui 18.2.0 → 18.2.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.
@@ -57,6 +57,48 @@ interface PlacementEmitState {
57
57
  */
58
58
  cellsArchived: boolean;
59
59
  }
60
+
61
+ /** A surface the renderer paints frames on. */
62
+ type Surface = "screen" | "alt";
63
+ const SURFACES: readonly Surface[] = ["screen", "alt"];
64
+
65
+ /**
66
+ * The live/text split of one drawing surface. The normal screen and the
67
+ * alternate buffer hold separate frames with separate display orders, so each
68
+ * carries its own thresholds: a modal's split describes the modal, and applying
69
+ * it to the transcript would demote — and purge — images the modal never showed.
70
+ */
71
+ interface SurfaceSplit {
72
+ /**
73
+ * Suppress threshold reflected in the frame currently on this surface: images
74
+ * at display indices `[0, onTerminal)` are shown as text there.
75
+ */
76
+ onTerminal: number;
77
+ /** Suppress threshold the current/next render of this surface should apply. */
78
+ planned: number;
79
+ /** Images the last full pass on this surface observed. */
80
+ lastTotal: number;
81
+ /**
82
+ * Image ids shown as text in the frame currently on this surface: the
83
+ * display-order prefix [0, onTerminal) of its last full pass, snapshotted by
84
+ * id so a partial pass reproduces the on-screen live/text split without a
85
+ * full, correctly-ordered walk.
86
+ */
87
+ suppressedIds: Set<number>;
88
+ }
89
+
90
+ function newSurfaceSplit(): SurfaceSplit {
91
+ return { onTerminal: 0, planned: 0, lastTotal: 0, suppressedIds: new Set() };
92
+ }
93
+
94
+ /** Return a split to "nothing has been painted on this surface yet", in place. */
95
+ function resetSurfaceSplit(split: SurfaceSplit): void {
96
+ split.onTerminal = 0;
97
+ split.planned = 0;
98
+ split.lastTotal = 0;
99
+ split.suppressedIds = new Set();
100
+ }
101
+
60
102
  let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
61
103
  function nextImageIdSeed(): number {
62
104
  nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
@@ -76,6 +118,16 @@ function nextImageIdSeed(): number {
76
118
  * rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
77
119
  * reports display order via {@link observe}; when that reveals a stricter split,
78
120
  * the TUI repeats the pass before emitting its terminal frame.
121
+ * Retired frames no longer observe their images, so the resident store is also
122
+ * bounded across passes. Evicting retired graphics removes their scrollback
123
+ * placements; a later replay can render or demote those images again.
124
+ *
125
+ * `cap` bounds one surface's live images, not the terminal's whole store. A
126
+ * fullscreen overlay's frame and the normal screen standing behind it are both
127
+ * on the terminal, and neither may delete the other's graphics — see
128
+ * {@link limitResidentImages} — so while a modal is up the store legitimately
129
+ * holds up to `cap` per surface. Read `cap` as "how many images one frame shows
130
+ * as graphics", not as a hard residency ceiling.
79
131
  *
80
132
  * `cap <= 0` disables budgeting: every image stays a live graphic.
81
133
  */
@@ -90,19 +142,29 @@ export class ImageBudget {
90
142
  /** Per-id suppression decision from the first observation in this pass. */
91
143
  #passSuppression = new Map<number, boolean>();
92
144
  /**
93
- * Suppress threshold reflected in the frame currently on the terminal: images
94
- * at display indices `[0, #onTerminal)` are shown as text there.
145
+ * Display index each observation was decided at, so {@link #passShowsLive}
146
+ * can re-check it against a reconciled threshold without scanning
147
+ * {@link #passIds} once per image.
95
148
  */
96
- #onTerminal = 0;
97
- /** Suppress threshold the current/next render should apply. */
98
- #planned = 0;
149
+ #passIndex = new Map<number, number>();
150
+ /** Live/text split of the normal screen. */
151
+ #screenSplit = newSurfaceSplit();
152
+ /** Live/text split of the alternate buffer (fullscreen overlay, resize borrow). */
153
+ #altSplit = newSurfaceSplit();
154
+ /** The split the in-flight pass reads and writes; selected by {@link beginPass}. */
155
+ #split = this.#screenSplit;
99
156
  /**
100
157
  * True while the in-flight pass applies a stricter threshold than the terminal
101
158
  * shows — the demotion frame that must purge graphics and fully repaint.
102
159
  */
103
160
  #applyingReset = false;
104
- #lastTotal = 0;
105
161
  #purgeIds: number[] = [];
162
+ /**
163
+ * Deletions that belong to a pending destructive reset, kept out of
164
+ * {@link #purgeIds} so only that reset's own repaint can emit them. See
165
+ * {@link forgetTransmitted}.
166
+ */
167
+ #resetPurgeIds: number[] = [];
106
168
  /** Image ids whose data is believed to be loaded in the terminal's store. */
107
169
  #transmitted = new Set<number>();
108
170
  /** Transmit sequences (full base64) to write once, before this frame's placements. */
@@ -112,11 +174,15 @@ export class ImageBudget {
112
174
  // tail, bottom-up. Such a pass cannot derive display order from observe()
113
175
  // call order, so its suppression decisions replay the committed split below.
114
176
  #stablePass = false;
115
- // Image ids shown as text in the frame currently on the terminal: the
116
- // display-order prefix [0, #onTerminal) of the last full pass, snapshotted by
117
- // id so a partial pass reproduces the on-screen live/text split without a
118
- // full, correctly-ordered walk.
119
- #suppressedIds = new Set<number>();
177
+ /** The surface the in-flight pass composes for; selected by {@link beginPass}. */
178
+ #surface: Surface = "screen";
179
+ /**
180
+ * Image ids rendered as live graphics by the frame standing on each surface.
181
+ * A pass walks one surface, so the other's entry is what stops {@link #retire}
182
+ * from deleting a graphic that is merely out of view — the transcript behind a
183
+ * fullscreen overlay keeps its placements and is restored from cache on exit.
184
+ */
185
+ #liveIds: Record<Surface, Set<number>> = { screen: new Set(), alt: new Set() };
120
186
  /**
121
187
  * Per-image direct-placement emit state: source pixel geometry for the
122
188
  * renderer's clipped source rectangle, plus the placement-id epoch (see
@@ -155,7 +221,7 @@ export class ImageBudget {
155
221
  const next = normalizeCap(cap);
156
222
  if (next === this.#cap) return;
157
223
  this.#cap = next;
158
- this.#reconcile(this.#lastTotal);
224
+ if (!this.#reconcile(this.#split.lastTotal)) this.#requestRender();
159
225
  }
160
226
 
161
227
  /**
@@ -178,18 +244,51 @@ export class ImageBudget {
178
244
  return id;
179
245
  }
180
246
 
247
+ /**
248
+ * Start an alternate-buffer lifecycle. Call once per `?1049h`, before the
249
+ * first pass of the fullscreen overlay or resize borrow that owns the buffer.
250
+ *
251
+ * The alt split is a claim about the frame standing on that surface, and
252
+ * `?1049h` hands over a cleared one: the previous occupant's threshold would
253
+ * suppress this buffer's leading images against a frame that no longer
254
+ * exists, painting them as text until a corrective render lands. Passes
255
+ * *within* one lifecycle must keep sharing the split — that is what lets an
256
+ * over-cap discovery pass converge before the frame is emitted.
257
+ */
258
+ beginAltScreenLifecycle(): void {
259
+ resetSurfaceSplit(this.#altSplit);
260
+ this.#liveIds.alt.clear();
261
+ }
262
+
181
263
  /**
182
264
  * Begin a render pass. Called by the renderer before composing the frame.
183
265
  * Pass `stable: true` for a partial/throwaway pass that does not walk the
184
266
  * whole tree in display order (the resize viewport fast path): {@link observe}
185
267
  * then replays the last committed per-id decision instead of one derived from
186
268
  * call order, and the pass must NOT be closed with {@link endPass}.
269
+ *
270
+ * Pass `altScreen: true` when the frame is painted on the alternate buffer
271
+ * (fullscreen overlay, resize borrow). The pass then reads and writes that
272
+ * surface's own {@link SurfaceSplit} and its live set adds to the recorded
273
+ * normal-screen one instead of replacing it, so a modal's threshold never
274
+ * reaches the transcript standing behind it.
187
275
  */
188
- beginPass(stable = false): void {
276
+ beginPass(stable = false, altScreen = false): void {
189
277
  this.#passIds.length = 0;
190
278
  this.#passSuppression.clear();
279
+ this.#passIndex.clear();
191
280
  this.#stablePass = stable;
192
- this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
281
+ this.#surface = altScreen ? "alt" : "screen";
282
+ this.#split = altScreen ? this.#altSplit : this.#screenSplit;
283
+ // Composing for the screen means the alternate buffer holds no frame to
284
+ // protect: live renders reach the normal-screen paths only when
285
+ // TUI#doRender has ruled out both alt-buffer owners, and the one caller
286
+ // outside that dispatch — the shutdown history flush — writes `?1049l`
287
+ // first. Note that leaving alt mode is not the same as unstacking a
288
+ // fullscreen overlay: the flush must exclude one that is still stacked
289
+ // from the pass itself, which is that caller's job, not this line's.
290
+ if (!altScreen) this.#liveIds.alt.clear();
291
+ this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
193
292
  }
194
293
 
195
294
  /**
@@ -198,22 +297,23 @@ export class ImageBudget {
198
297
  * on a cache hit, so the image keeps its display-order slot.
199
298
  *
200
299
  * During a `stable` pass ({@link beginPass}) the call order and visible subset
201
- * are not authoritative, so the decision is the committed on-terminal split
202
- * (`#suppressedIds`) keyed by id — order- and partiality-independent.
300
+ * are not authoritative, so the decision is the surface's committed
301
+ * on-terminal split, keyed by id — order- and partiality-independent.
203
302
  */
204
303
  observe(imageId: number): boolean {
205
304
  const existing = this.#passSuppression.get(imageId);
206
305
  if (existing !== undefined) return existing;
207
306
  if (this.#stablePass) {
208
- const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
307
+ const suppressed = this.#cap > 0 && this.#split.suppressedIds.has(imageId);
209
308
  this.#passSuppression.set(imageId, suppressed);
210
309
  if (suppressed) this.#forgetKeyForId(imageId);
211
310
  return suppressed;
212
311
  }
213
312
  const index = this.#passIds.length;
214
313
  this.#passIds.push(imageId);
215
- const suppressed = this.#cap > 0 && index < this.#planned;
314
+ const suppressed = this.#cap > 0 && index < this.#split.planned;
216
315
  this.#passSuppression.set(imageId, suppressed);
316
+ this.#passIndex.set(imageId, index);
217
317
  if (suppressed) this.#forgetKeyForId(imageId);
218
318
  return suppressed;
219
319
  }
@@ -224,29 +324,101 @@ export class ImageBudget {
224
324
  */
225
325
  endPass(): boolean {
226
326
  const total = this.#passIds.length;
227
- this.#lastTotal = total;
327
+ const split = this.#split;
328
+ split.lastTotal = total;
228
329
  if (this.#applyingReset) {
229
- for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
230
- const id = this.#passIds[i];
231
- // A transmit queued by a discarded discovery pass never reached
232
- // the terminal, so cancel it instead of transmitting then purging.
233
- if (!this.#pendingTransmits.delete(id)) this.#purgeIds.push(id);
234
- this.#transmitted.delete(id);
235
- this.#deletePlacementState(id);
236
- this.#forgetKeyForId(id);
330
+ // This frame replaced these with their text fallback, so their graphics
331
+ // are retired as far as this surface is concerned.
332
+ for (let i = split.onTerminal; i < split.planned && i < total; i++) {
333
+ this.#retire(this.#passIds[i]);
237
334
  }
238
- this.#onTerminal = this.#planned;
335
+ split.onTerminal = split.planned;
239
336
  this.#applyingReset = false;
240
337
  }
241
338
  const retry = this.#reconcile(total);
242
339
  // Snapshot the committed display-order suppression by id: the prefix
243
- // [0, #onTerminal) is what the terminal currently shows as text. Partial
340
+ // [0, onTerminal) is what this surface currently shows as text. Partial
244
341
  // passes replay this per id (see #stablePass) instead of re-deriving it
245
342
  // from a reversed, tail-only walk.
246
- this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
343
+ split.suppressedIds = new Set(this.#passIds.slice(0, split.onTerminal));
247
344
  return retry;
248
345
  }
249
346
 
347
+ /**
348
+ * Bound the terminal's image store to `cap`. Demotion ({@link endPass}) already
349
+ * retires the graphics this frame replaced with text; this sweeps the ones no
350
+ * frame shows any more — images the pass simply stopped observing.
351
+ *
352
+ * Also records what this frame leaves standing on its surface, which is how
353
+ * the next pass on the *other* surface knows what it may not destroy.
354
+ */
355
+ limitResidentImages(): void {
356
+ 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;
360
+ this.#retire(id);
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Whether this pass leaves `imageId` on its surface as a live graphic.
366
+ *
367
+ * Not simply "was not suppressed". A pass decides suppression from the
368
+ * threshold standing at {@link beginPass}, and {@link endPass} may then
369
+ * reconcile that threshold *downwards* — the frame is emitted with a text
370
+ * fallback the very next frame will replace with the graphic again. Reading
371
+ * such a decision as retirement would delete an image the surface is about to
372
+ * show, and `d=I` takes placements no repaint can restore. So a suppression
373
+ * the reconcile has since undercut counts as live.
374
+ */
375
+ #passShowsLive(imageId: number): boolean {
376
+ const suppressed = this.#passSuppression.get(imageId);
377
+ if (suppressed === undefined) return false;
378
+ if (!suppressed) return true;
379
+ // Absent (a `stable` pass replays the committed split rather than deriving
380
+ // an order) counts as live, so an unknown decision never authorises a
381
+ // delete.
382
+ const index = this.#passIndex.get(imageId);
383
+ return index === undefined || index >= this.#split.planned;
384
+ }
385
+
386
+ /**
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.
389
+ *
390
+ * 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.
396
+ */
397
+ #retire(imageId: number): boolean {
398
+ if (this.#passShowsLive(imageId)) return false;
399
+ for (const surface of SURFACES) {
400
+ if (surface !== this.#surface && this.#liveIds[surface].has(imageId)) return false;
401
+ }
402
+ // A transmit queued by a discarded discovery pass never reached the
403
+ // 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);
407
+ this.#forgetKeyForId(imageId);
408
+ return true;
409
+ }
410
+
411
+ /**
412
+ * Image ids a destructive reset must delete explicitly, alongside its `d=A`.
413
+ * Emit only from that reset's repaint; clears the queue.
414
+ */
415
+ takeResetPurgeIds(): readonly number[] {
416
+ if (this.#resetPurgeIds.length === 0) return EMPTY_IDS;
417
+ const ids = this.#resetPurgeIds;
418
+ this.#resetPurgeIds = [];
419
+ return ids;
420
+ }
421
+
250
422
  /** Image ids to delete from the terminal this frame; clears the pending set. */
251
423
  takePurgeIds(): readonly number[] {
252
424
  if (this.#purgeIds.length === 0) return EMPTY_IDS;
@@ -261,11 +433,13 @@ export class ImageBudget {
261
433
  const ids = [...this.#transmitted];
262
434
  this.#transmitted.clear();
263
435
  this.#purgeIds = [];
436
+ this.#resetPurgeIds = [];
264
437
  this.#pendingTransmits.clear();
265
438
  this.#keyToId.clear();
266
439
  this.#idToKey.clear();
267
440
  this.#placementState.clear();
268
441
  this.#watchedPlacements.clear();
442
+ for (const surface of SURFACES) this.#liveIds[surface].clear();
269
443
  return ids;
270
444
  }
271
445
 
@@ -407,18 +581,17 @@ export class ImageBudget {
407
581
  }
408
582
 
409
583
  /**
410
- * True when the budget has nothing in flight: no live images observed on
411
- * the last pass, no queued transmits, no pending purges, and no stricter
412
- * threshold left to apply. A component-scoped frame may skip the observe
413
- * pass only then — a partial tree walk would under-count display order.
584
+ * True when the budget has nothing in flight on either surface: no live images
585
+ * observed on the last pass, no queued transmits, no pending purges, and no
586
+ * stricter threshold left to apply. A component-scoped frame may skip the
587
+ * observe pass only then — a partial tree walk would under-count display order.
414
588
  */
415
589
  get quiescent(): boolean {
416
- return (
417
- this.#lastTotal === 0 &&
418
- this.#pendingTransmits.size === 0 &&
419
- this.#purgeIds.length === 0 &&
420
- this.#planned === this.#onTerminal
421
- );
590
+ if (this.#pendingTransmits.size > 0 || this.#purgeIds.length > 0) return false;
591
+ for (const split of [this.#screenSplit, this.#altSplit]) {
592
+ if (split.lastTotal !== 0 || split.planned !== split.onTerminal) return false;
593
+ }
594
+ return true;
422
595
  }
423
596
 
424
597
  /** Transmit sequences to write before this frame's placements; clears the queue. */
@@ -439,11 +612,36 @@ export class ImageBudget {
439
612
  */
440
613
  forgetTransmitted(): void {
441
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);
617
+ }
618
+ // The ids go to #resetPurgeIds, drained only by the destructive repaint
619
+ // itself — never to #purgeIds, which any frame drains. That is how a
620
+ // deletion used to ride out on an alternate-buffer frame and leave the
621
+ // normal screen blank with no repaint left to restore it.
622
+ //
623
+ // `d=A` alone is not enough to skip these: Kitty excludes *virtual*
624
+ // placements from it, and erasing placeholder text does not remove the
625
+ // prototype either. Forgetting drops the id from tracking, so without an
626
+ // explicit `d=I` no later sweep can ever find that placement again.
442
627
  this.#transmitted.clear();
443
628
  this.#pendingTransmits.clear();
444
629
  }
445
630
 
631
+ /**
632
+ * Release `id`'s stable key so a component recreated under it gets a fresh id
633
+ * — but only once the terminal no longer holds `id`'s data, because a key must
634
+ * never resolve to an id whose graphic is gone.
635
+ *
636
+ * Key lifetime follows residency, not the live/text split. The two usually
637
+ * agree: an image shown as text has had its graphic purged. They diverge when
638
+ * {@link #retire} refuses, which leaves a suppressed image resident on another
639
+ * surface — and releasing a resident id's key orphans it. The recreation mints
640
+ * a new id, nothing observes the old one again, and the next pass retires it,
641
+ * deleting every placement it ever made including scrollback copies.
642
+ */
446
643
  #forgetKeyForId(id: number): void {
644
+ if (this.#transmitted.has(id)) return;
447
645
  const key = this.#idToKey.get(id);
448
646
  if (key === undefined) return;
449
647
  this.#idToKey.delete(id);
@@ -451,21 +649,22 @@ export class ImageBudget {
451
649
  }
452
650
 
453
651
  #reconcile(total: number): boolean {
652
+ const split = this.#split;
454
653
  const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
455
- if (desired === this.#planned) {
654
+ if (desired === split.planned) {
456
655
  // Budget relaxed without a stricter frame (cap raised or images
457
656
  // removed): surviving graphics are untouched and re-exposed rows
458
657
  // repaint normally, so just track the looser threshold.
459
- if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
658
+ if (split.planned < split.onTerminal) split.onTerminal = split.planned;
460
659
  return false;
461
660
  }
462
- const retry = desired > this.#onTerminal;
463
- this.#planned = desired;
661
+ const retry = desired > split.onTerminal;
662
+ split.planned = desired;
464
663
  // More images must be demoted than the terminal shows: schedule the purge +
465
664
  // full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
466
665
  // up — a normal repaint re-exposes the un-demoted images. Either way a
467
666
  // render is needed to apply the new threshold.
468
- if (desired <= this.#onTerminal) this.#onTerminal = desired;
667
+ if (desired <= split.onTerminal) split.onTerminal = desired;
469
668
  this.#requestRender();
470
669
  return retry;
471
670
  }
@@ -325,9 +325,11 @@ export function isWindowsTerminalPreviewSixelSupported(
325
325
  env: NodeJS.ProcessEnv = Bun.env,
326
326
  platform: NodeJS.Platform = process.platform,
327
327
  ): boolean {
328
- if (platform !== "win32") return false;
329
- if (!env.WT_SESSION) return false;
330
- if (env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal") {
328
+ if (
329
+ platform !== "win32" ||
330
+ !env.WT_SESSION ||
331
+ (env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal")
332
+ ) {
331
333
  return false;
332
334
  }
333
335
  const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
@@ -386,7 +388,7 @@ export function shouldEnableSynchronizedOutputByDefault(
386
388
  if (override !== null) return override;
387
389
 
388
390
  if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
389
- if (env.WT_SESSION) return true;
391
+ if (env.WT_SESSION && (!env.TERM_PROGRAM || env.TERM_PROGRAM.toLowerCase() === "windows_terminal")) return true;
390
392
  if (isInsideHerdr(env)) return true;
391
393
 
392
394
  // Risky multiplexers start off even when an inner terminal id leaks through:
@@ -442,6 +444,45 @@ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS.
442
444
  }
443
445
  return true;
444
446
  }
447
+ /**
448
+ * Whether the terminal implements colon-subparameter SGR styled underlines —
449
+ * `CSI 4 : 3 m` (curly) plus `CSI 58` / `CSI 59` underline color — as opposed to
450
+ * only the legacy `CSI 4 m` / `CSI 24 m` on/off underline.
451
+ *
452
+ * This is an underline-style capability, not a color depth, so it is keyed on
453
+ * the detected terminal, never on `TERM`/`COLORTERM`. kitty, Ghostty, WezTerm,
454
+ * and iTerm2 (>= 3.5) implement the full pair. Apple Terminal does NOT: it
455
+ * renders `CSI 4 : 0 m` (the reset half) as a solid black background that
456
+ * persists to end of line, and ignores SGR 58/59 — so it, along with every
457
+ * other unproven terminal, gets the flat underline instead. Disabled under any
458
+ * multiplexer: GNU screen and older tmux drop colon-form SGR, and the outer
459
+ * terminal's id leaks into the session env, so a proven id is not proof the
460
+ * bytes survive — the same reason DECCARA and synchronized output gate on it.
461
+ */
462
+ export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS.ProcessEnv = Bun.env): boolean {
463
+ // A multiplexer in the path (GNU screen, older tmux) does not forward the
464
+ // colon-form underline, yet the outer terminal's id leaks through the session
465
+ // env, so the switch below would otherwise trust an unreachable capability.
466
+ if (isInsideTerminalMultiplexer(env)) return false;
467
+ switch (terminalId) {
468
+ case "kitty":
469
+ case "ghostty":
470
+ case "wezterm":
471
+ return true;
472
+ case "iterm2": {
473
+ // The full curly-and-colored pair did not ship together until iTerm2 3.5
474
+ // (curly first targeted 3.3.12; SGR 58/59 underline color was beta,
475
+ // expected for 3.5), so 3.0–3.4 would receive the colon reset they cannot
476
+ // render. Enable only on a confirmed major.minor >= 3.5; an absent or
477
+ // unparseable version keeps the flat fallback so only proven terminals
478
+ // get the colon form.
479
+ const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
480
+ return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
481
+ }
482
+ default:
483
+ return false;
484
+ }
485
+ }
445
486
  /**
446
487
  * Resolve an explicit user override for OSC 8 hyperlinks. Returns `false` for
447
488
  * an opt-out, `true` for a force-on, or `null` when the user has expressed no
@@ -689,6 +730,8 @@ export interface RuntimeTerminal extends TerminalInfo {
689
730
  supportsScreenToScrollback: boolean;
690
731
  /** Whether OSC 66 text sizing is currently enabled. */
691
732
  textSizing: boolean;
733
+ /** Whether the terminal implements colon-subparameter styled underlines (curly + colored). */
734
+ styledUnderlines: boolean;
692
735
  }
693
736
 
694
737
  export const TERMINAL: RuntimeTerminal = (() => {
@@ -715,6 +758,11 @@ export const TERMINAL: RuntimeTerminal = (() => {
715
758
  // ignores DECCARA) exercises the padded-string fallback. Integration tests opt
716
759
  // in explicitly through setTerminalDeccara.
717
760
  resolved.deccara = detectRectangularSgrSupport(resolved.id, Bun.env) && !isBunTestRuntime();
761
+ // Styled-underline capability: colon-form curly underline + SGR 58/59 color.
762
+ // Keyed on the detected terminal (an underline-style capability, not a color
763
+ // depth), so Apple Terminal and other unproven hosts fall back to the flat
764
+ // CSI 4 m / CSI 24 m underline the typo renderer needs to avoid black bars.
765
+ resolved.styledUnderlines = detectStyledUnderlineSupport(resolved.id, Bun.env);
718
766
  return resolved;
719
767
  })();
720
768
 
@@ -10,15 +10,36 @@ export function isInsideHerdr(env: NodeJS.ProcessEnv = Bun.env): boolean {
10
10
  return false;
11
11
  }
12
12
 
13
- /** Detect whether a terminal multiplexer owns the current screen grid. */
14
- export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
15
- // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
16
- // markers are authoritative session signals. TERM can also survive when those are
17
- // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
18
- // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
19
- // outside a CMUX terminal.
20
- if (env.TMUX || env.STY || env.ZELLIJ || isInsideHerdr(env)) return true;
21
- if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
13
+ /** Terminal multiplexers omp recognizes as owning the screen grid. */
14
+ export type TerminalMultiplexer = "herdr" | "tmux" | "screen" | "zellij" | "cmux" | "wmux";
15
+
16
+ /**
17
+ * Classify which terminal multiplexer owns the current screen grid, or `null`
18
+ * for a direct terminal. Single source of truth for both the render-path gate
19
+ * (`isInsideTerminalMultiplexer`) and the debug snapshot label.
20
+ *
21
+ * TMUX/STY/ZELLIJ, Herdr, and the CMUX/WMUX workspace/surface/remote-transport
22
+ * markers are authoritative session signals. TERM can also survive when those
23
+ * are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
24
+ * use CMUX_SOCKET_PATH / WMUX_CLI / WMUX_PIPE here: they are CLI socket/path
25
+ * overrides and can be set outside a CMUX/WMUX terminal. wmux is a Windows
26
+ * multiplexer (Electron + xterm.js) modeled on cmux/herdr that repaints its
27
+ * pane in place and exports WMUX=1 plus a native WMUX_SURFACE_ID.
28
+ */
29
+ export function classifyTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): TerminalMultiplexer | null {
30
+ if (isInsideHerdr(env)) return "herdr";
31
+ if (env.TMUX) return "tmux";
32
+ if (env.STY) return "screen";
33
+ if (env.ZELLIJ) return "zellij";
34
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return "cmux";
35
+ if (env.WMUX === "1" || env.WMUX_SURFACE_ID) return "wmux";
22
36
  const term = env.TERM?.toLowerCase() ?? "";
23
- return term.startsWith("tmux") || term.startsWith("screen");
37
+ if (term.startsWith("tmux")) return "tmux";
38
+ if (term.startsWith("screen")) return "screen";
39
+ return null;
40
+ }
41
+
42
+ /** True when a terminal multiplexer owns the current screen grid. */
43
+ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
44
+ return classifyTerminalMultiplexer(env) !== null;
24
45
  }
package/src/terminal.ts CHANGED
@@ -514,6 +514,20 @@ export interface Terminal {
514
514
  */
515
515
  readonly pendingOutputBytes?: number;
516
516
 
517
+ /**
518
+ * Whether a pseudoconsole host owns the grid this terminal writes to, so
519
+ * neither the cursor nor the painted rows survive a resize under the
520
+ * application's own model. Measured on Windows conhost: resizing the
521
+ * pseudoconsole makes it re-emit its whole viewport from `CSI H` with
522
+ * absolute addressing while the application writes nothing, and it re-homes
523
+ * the cursor, so a DSR reply after a resize reports column 1 instead of the
524
+ * column the application parked. The renderer's resize anchor recovery needs
525
+ * both properties, so it takes the rebuild path instead when this is set.
526
+ * Optional so custom Terminals built against older pi-tui versions keep
527
+ * working; absent means the terminal itself owns the grid.
528
+ */
529
+ readonly hostOwnsGridOnResize?: boolean;
530
+
517
531
  // Whether Kitty keyboard protocol is active
518
532
  get kittyProtocolActive(): boolean;
519
533
 
@@ -656,8 +670,9 @@ function isPrivateModeSupported(status: string): boolean {
656
670
  export interface ProcessTerminalOptions {
657
671
  /**
658
672
  * Force ConPTY-hosted behavior on or off. Defaults to live detection via
659
- * {@link isConPTYHosted}. Tests set this so the kitty-flag and write-chunking
660
- * paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
673
+ * {@link isConPTYHosted}. Tests set this so the kitty-flag, write-chunking
674
+ * and resize-routing ({@link Terminal.hostOwnsGridOnResize}) paths stay
675
+ * hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
661
676
  * `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
662
677
  */
663
678
  conpty?: boolean;
@@ -1989,6 +2004,14 @@ export class ProcessTerminal implements Terminal {
1989
2004
  return process.stdout.writableLength ?? 0;
1990
2005
  }
1991
2006
 
2007
+ get hostOwnsGridOnResize(): boolean {
2008
+ // #conpty, not a fresh isConPTYHosted() call: the construction override
2009
+ // must gate every ConPTY-dependent path uniformly, or an injected
2010
+ // `conpty` value models one host for writes and kitty flags and the
2011
+ // opposite host for resize routing.
2012
+ return this.#conpty;
2013
+ }
2014
+
1992
2015
  /**
1993
2016
  * Reconcile the stdout backlog after a write or a poll. The watchdog runs an
1994
2017
  * episode from the moment the backlog crosses the arm cap until it drains to