@oh-my-pi/pi-tui 15.9.1 → 15.9.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.3] - 2026-06-05
6
+
7
+ ### Fixed
8
+
9
+ - Fixed ED3-risk foreground streaming erasing the head of any block that alone overflows the viewport (a tall tool result drawn in one frame, or a multi-line assistant reply growing past the viewport as it streams). The live-region pin committed native scrollback only up to the sealed-prefix boundary (`liveRegionStart`), so rows of the live block that had physically scrolled above the viewport top were neither pushed into scrollback nor kept in the repainted viewport — they vanished. The commit boundary is now the viewport top: every row above the viewport enters scrollback (only the tail still visible in the viewport stays transient and deferred to the checkpoint).
10
+ - Fixed the same ED3-risk live-region pin duplicating already-committed scrollback rows when a foreground stream's live region collapsed mid-turn (a tool preview shrinking to its compact result, an assistant block re-wrapping shorter, a late tool completion). Because growth commits every row above the viewport top to native scrollback, a subsequent shrink moved the bottom-anchored viewport back across those committed rows and the repaint re-drew them into the viewport — so they appeared twice on scroll-up, and with no prompt-submit checkpoint to reconcile (autonomous multi-turn runs, or the session ending into the welcome screen) the duplicate was baked permanently into terminal history. The pinned repaint now separates commit geometry from repaint geometry: a collapse clamps the repaint to the committed sealed boundary (`min(#scrollbackHighWater, liveRegionStart)`) instead of re-exposing those rows, leaving native scrollback un-duplicated without emitting ED3 under a possibly-scrolled reader; stale mutable live-region saved lines still reconcile at the next checkpoint.
11
+ - Fixed hiding overlays during ED3-risk foreground streaming on unknown-viewport terminals leaving the overlay's transient rows in native scrollback. Overlay visibility reductions now bypass the streaming deferral path and rebuild once, so hidden dialog/notification sentinels are scrubbed immediately.
12
+ - Fixed ED3-risk / unknown-viewport terminals (including WSL fronted by Windows Terminal) keeping the foreground-stream eager-rebuild mode active after the stream had already settled. A later scrolled content shrink or resize-with-append could then bypass the anti-yank deferral and repaint from stale geometry, jumping the viewport or replaying the wrong rows. The eager opt-in now drops immediately when no teardown render is pending, and the one-frame post-checkpoint suffix-suppression path no longer overrides geometry reflow handling.
13
+
14
+ ## [15.9.2] - 2026-06-05
15
+
16
+ ### Changed
17
+
18
+ - Changed foreground-stream rendering on ED3-risk terminals (Ghostty/kitty/Alacritty/VTE/iTerm2 on POSIX) to defer native-scrollback commits for unpinned transient frames: while a turn streams, generic frames repaint only the viewport and suppress `\r\n` scroll growth, so transient output (spinner ticks, partial lines, status rows) never pollutes terminal history. Components that report a `NativeScrollbackLiveRegion` still commit newly sealed prefix rows while keeping the active suffix dirty for checkpoint replay. Native scrollback is reconciled in a single ED3 (`CSI 3 J`) + re-emit at the next checkpoint (prompt submit) or on an explicit user-input/IME opt-in; an erase is never emitted mid-stream under a possibly-scrolled reader. Non-ED3-risk terminals keep their eager live rebuild. ([#1895](https://github.com/can1357/oh-my-pi/pull/1895))
19
+
20
+ ### Fixed
21
+
22
+ - Fixed ED3-risk foreground streaming dropping sealed transcript rows above the live block until the next prompt-submit checkpoint, which made scrollback beyond the viewport appear duplicated or out of order. The renderer restores native-scrollback live-region pinning so newly sealed rows are appended once while active live rows remain deferred.
23
+ - Fixed inline images (added in 15.9) rendering as a wall of empty PUA box glyphs and producing laggy scrolling on Kitty-protocol terminals that do not implement Unicode placeholders — most notably WezTerm (per upstream wezterm/wezterm#986, placeholder support is still unchecked) and the tmux/screen `getFallbackImageProtocol` path that forces Kitty mode even on non-supporting outer terminals (Terminal.app, etc.). `unicodePlaceholders` now defaults on only for `kitty` and `ghostty`; everything else falls back to direct `a=p,i=…,p=…` placement, which those paths already render correctly. `PI_NO_KITTY_PLACEHOLDERS=1` is still honored as a hard opt-out, and a new `PI_KITTY_PLACEHOLDERS=1` opts in on otherwise-unsupported terminals (e.g. a wezterm nightly that has merged placeholder support) ([#1877](https://github.com/can1357/oh-my-pi/issues/1877)).
24
+
5
25
  ## [15.9.1] - 2026-06-04
26
+
6
27
  ### Fixed
7
28
 
8
29
  - Fixed the OSC 11 appearance poll re-querying every 2s forever on terminals that support Mode 2031 but never change theme, whose repeated OSC 11/DA1 writes cleared the user's active text selection (breaking copy every 2 seconds). The poll now stops as soon as DECRQM confirms Mode 2031 support, since push notifications make polling redundant.
@@ -9,6 +9,24 @@ export interface KittyGraphicsFeatures {
9
9
  /** How image data reaches the terminal: in-band base64 or a temp file. */
10
10
  transmissionMedium: KittyTransmissionMedium;
11
11
  }
12
+ /**
13
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
14
+ * U+10EEEE with row/column diacritics).
15
+ *
16
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
17
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
18
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
19
+ * still unchecked), and the tmux/screen fallback can land on any outer
20
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
21
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
22
+ * glyphs and re-emit on every repaint, which is exactly the
23
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
24
+ *
25
+ * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
26
+ * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
27
+ * for a wezterm nightly that has merged placeholder support).
28
+ */
29
+ export declare function detectKittyUnicodePlaceholdersSupport(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
12
30
  export declare function getKittyGraphics(): Readonly<KittyGraphicsFeatures>;
13
31
  export declare function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void;
14
32
  /**
@@ -42,6 +42,16 @@ export interface Component {
42
42
  */
43
43
  invalidate(): void;
44
44
  }
45
+ /**
46
+ * Optional component seam for native-scrollback pinning. A component that
47
+ * renders a stable prefix followed by a live/transient suffix reports the local
48
+ * line index where that suffix begins after each render. TUI treats that suffix
49
+ * — and every root child rendered below it — as not yet safe to commit to native
50
+ * scrollback on ED3-risk terminals whose viewport position is unobservable.
51
+ */
52
+ export interface NativeScrollbackLiveRegion {
53
+ getNativeScrollbackLiveRegionStart(): number | undefined;
54
+ }
45
55
  /**
46
56
  * Interface for components that can receive focus and display a cursor.
47
57
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -173,6 +183,7 @@ export declare class TUI extends Container {
173
183
  hidden: boolean;
174
184
  }[];
175
185
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
186
+ render(width: number): string[];
176
187
  get fullRedraws(): number;
177
188
  /** Shared budget that caps how many inline images render as live graphics. */
178
189
  get imageBudget(): ImageBudget;
@@ -212,12 +223,13 @@ export declare class TUI extends Container {
212
223
  * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
213
224
  * direct user-input rebuilds are unaffected.
214
225
  *
215
- * Disabling does not take effect until the next frame has been classified:
216
- * the event batch that ends a foreground stream both removes its UI rows
217
- * (loader/status teardown — a shrink) and clears this flag before the
218
- * throttled render timer fires. If the flag dropped immediately, that
219
- * teardown frame would hit the ED3-risk idle deferral and freeze on screen
220
- * (stale spinner) until the next keystroke.
226
+ * Disabling stays active through one already-requested frame: the event batch
227
+ * that ends a foreground stream both removes its UI rows (loader/status
228
+ * teardown — a shrink) and clears this flag before the throttled render timer
229
+ * fires. If the flag dropped immediately, that teardown frame would hit the
230
+ * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
231
+ * keystroke. When no render is pending, disable immediately so a later
232
+ * unrelated content mutation does not inherit foreground-stream privileges.
221
233
  */
222
234
  setEagerNativeScrollbackRebuild(enabled: boolean): void;
223
235
  setFocus(component: Component | null): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.9.1",
4
+ "version": "15.9.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.9.1",
41
- "@oh-my-pi/pi-utils": "15.9.1",
40
+ "@oh-my-pi/pi-natives": "15.9.3",
41
+ "@oh-my-pi/pi-utils": "15.9.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -17,7 +17,7 @@
17
17
  import * as fs from "node:fs";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
- import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
20
+ import { $env, logger } from "@oh-my-pi/pi-utils";
21
21
 
22
22
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
23
23
  export const KITTY_PLACEHOLDER = "\u{10eeee}";
@@ -76,8 +76,36 @@ function transmissionOverride(): KittyTransmissionMedium | "auto" {
76
76
  return "auto";
77
77
  }
78
78
 
79
+ /**
80
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
81
+ * U+10EEEE with row/column diacritics).
82
+ *
83
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
84
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
85
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
86
+ * still unchecked), and the tmux/screen fallback can land on any outer
87
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
88
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
89
+ * glyphs and re-emit on every repaint, which is exactly the
90
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
91
+ *
92
+ * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
93
+ * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
94
+ * for a wezterm nightly that has merged placeholder support).
95
+ */
96
+ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
97
+ const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
98
+ if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
99
+ const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
100
+ if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
101
+ if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
102
+ return terminalId === "kitty" || terminalId === "ghostty";
103
+ }
104
+
79
105
  let features: KittyGraphicsFeatures = {
80
- unicodePlaceholders: !$flag("PI_NO_KITTY_PLACEHOLDERS"),
106
+ // Off until `terminal-capabilities` seeds it from the detected terminal id —
107
+ // the default-on path corrupts wezterm and tmux-passthrough sessions.
108
+ unicodePlaceholders: false,
81
109
  // Start direct; a successful probe (or explicit `temp-file` override) promotes.
82
110
  transmissionMedium: transmissionOverride() === "temp-file" ? "temp-file" : "direct",
83
111
  };
@@ -1,12 +1,14 @@
1
1
  import { encodeSixel } from "@oh-my-pi/pi-natives";
2
2
  import { $env, isBunTestRuntime } from "@oh-my-pi/pi-utils";
3
3
  import {
4
+ detectKittyUnicodePlaceholdersSupport,
4
5
  encodeKittyTempFileTransmit,
5
6
  getKittyGraphics,
6
7
  isPngBase64,
7
8
  KITTY_PLACEHOLDER,
8
9
  kittyPlaceholdersFit,
9
10
  renderKittyPlaceholderLines,
11
+ setKittyGraphics,
10
12
  } from "./kitty-graphics";
11
13
 
12
14
  export enum ImageProtocol {
@@ -321,6 +323,12 @@ export const TERMINAL = (() => {
321
323
  return resolved;
322
324
  })();
323
325
 
326
+ // Seed Kitty Unicode placeholder support from the resolved terminal id. Only
327
+ // kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
328
+ // (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
329
+ // glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
330
+ setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
331
+
324
332
  type MutableTerminalInfo = {
325
333
  imageProtocol: ImageProtocol | null;
326
334
  deccara: boolean;
package/src/tui.ts CHANGED
@@ -117,6 +117,21 @@ export interface Component {
117
117
  invalidate(): void;
118
118
  }
119
119
 
120
+ /**
121
+ * Optional component seam for native-scrollback pinning. A component that
122
+ * renders a stable prefix followed by a live/transient suffix reports the local
123
+ * line index where that suffix begins after each render. TUI treats that suffix
124
+ * — and every root child rendered below it — as not yet safe to commit to native
125
+ * scrollback on ED3-risk terminals whose viewport position is unobservable.
126
+ */
127
+ export interface NativeScrollbackLiveRegion {
128
+ getNativeScrollbackLiveRegionStart(): number | undefined;
129
+ }
130
+
131
+ function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
132
+ return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
133
+ }
134
+
120
135
  /**
121
136
  * Interface for components that can receive focus and display a cursor.
122
137
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -317,6 +332,9 @@ export class Container implements Component {
317
332
  * - `historyRebuild`: a geometry change (terminal resize) left native history
318
333
  * wrapped at the old size — clear viewport and scrollback so it rewraps at the
319
334
  * new geometry. Also flushes deferred content-only rewrites.
335
+ * - `liveRegionPinned`: ED3-risk/unknown foreground stream with a reported live
336
+ * suffix — optionally append newly sealed rows, then repaint the live/mutable
337
+ * tail without letting transient rows enter native history.
320
338
  * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
321
339
  * is set, emit those tail rows as scrollback growth first so streaming
322
340
  * output reaches terminal history before the corrected viewport is drawn.
@@ -334,6 +352,7 @@ type RenderIntent =
334
352
  | { kind: "sessionReplace" }
335
353
  | { kind: "historyRebuild" }
336
354
  | { kind: "overlayRebuild" }
355
+ | { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
337
356
  | { kind: "viewportRepaint"; appendFrom?: number }
338
357
  | { kind: "deferredShrink"; paddedLength: number }
339
358
  | { kind: "deferredMutation" }
@@ -383,7 +402,18 @@ export class TUI extends Container {
383
402
  // Set after a clear+full replay so the next insert-above-suffix frame does
384
403
  // not scroll replayed live chrome (status/editor) into fresh history.
385
404
  #suppressNextSuffixScroll = false;
405
+ #nativeScrollbackLiveRegionStart: number | undefined;
386
406
  #nativeScrollbackDirty = false;
407
+ // Highest `#maxLinesRendered` reached during a foreground tool turn while
408
+ // intermediate frames were prevented from committing to terminal scrollback.
409
+ // Used after the tool finishes to push the settled content into scrollback
410
+ // via a non-destructive full paint (no ED 3). Reset to 0 once rows are
411
+ // committed (via any `#emitFullPaint`, `#emitDiff`, or `#emitAppendTail`
412
+ // path).
413
+ #streamingHighWater = 0;
414
+ // Tracks whether the previous frame was inside a foreground tool streaming
415
+ // turn. Used to reset `#streamingHighWater` on fresh streaming starts.
416
+ #previousStreamingActive = false;
387
417
  #fullRedrawCount = 0;
388
418
  // Caps how many inline images render as live graphics; older ones fall back
389
419
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -423,6 +453,25 @@ export class TUI extends Container {
423
453
  this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
424
454
  }
425
455
 
456
+ override render(width: number): string[] {
457
+ width = Math.max(1, width);
458
+ this.#nativeScrollbackLiveRegionStart = undefined;
459
+ const lines: string[] = [];
460
+ for (const child of this.children) {
461
+ const offset = lines.length;
462
+ const childLines = child.render(width);
463
+ const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
464
+ if (liveRegionStart !== undefined) {
465
+ const boundedStart = Number.isFinite(liveRegionStart)
466
+ ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
467
+ : childLines.length;
468
+ this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
469
+ }
470
+ lines.push(...childLines);
471
+ }
472
+ return lines;
473
+ }
474
+
426
475
  #syncTerminalCursorMode(component: Component | null): void {
427
476
  if (isFocusable(component)) {
428
477
  component.setUseTerminalCursor?.(this.#showHardwareCursor);
@@ -498,12 +547,13 @@ export class TUI extends Container {
498
547
  * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
499
548
  * direct user-input rebuilds are unaffected.
500
549
  *
501
- * Disabling does not take effect until the next frame has been classified:
502
- * the event batch that ends a foreground stream both removes its UI rows
503
- * (loader/status teardown — a shrink) and clears this flag before the
504
- * throttled render timer fires. If the flag dropped immediately, that
505
- * teardown frame would hit the ED3-risk idle deferral and freeze on screen
506
- * (stale spinner) until the next keystroke.
550
+ * Disabling stays active through one already-requested frame: the event batch
551
+ * that ends a foreground stream both removes its UI rows (loader/status
552
+ * teardown — a shrink) and clears this flag before the throttled render timer
553
+ * fires. If the flag dropped immediately, that teardown frame would hit the
554
+ * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
555
+ * keystroke. When no render is pending, disable immediately so a later
556
+ * unrelated content mutation does not inherit foreground-stream privileges.
507
557
  */
508
558
  setEagerNativeScrollbackRebuild(enabled: boolean): void {
509
559
  if (enabled) {
@@ -512,7 +562,16 @@ export class TUI extends Container {
512
562
  return;
513
563
  }
514
564
  if (!this.#eagerNativeScrollbackRebuild) return;
515
- this.#eagerNativeScrollbackRebuildDisablePending = true;
565
+ if (this.#renderRequested || this.#renderTimer !== undefined) {
566
+ this.#eagerNativeScrollbackRebuildDisablePending = true;
567
+ return;
568
+ }
569
+ if (process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk) {
570
+ this.#streamingHighWater = 0;
571
+ this.#markNativeScrollbackDirty();
572
+ }
573
+ this.#eagerNativeScrollbackRebuild = false;
574
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
516
575
  }
517
576
 
518
577
  setFocus(component: Component | null): void {
@@ -1383,11 +1442,12 @@ export class TUI extends Container {
1383
1442
  (resizeEventOccurred && this.#previousHeight > 0);
1384
1443
  const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
1385
1444
  const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
1386
- const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender || eagerRebuildAllowed;
1445
+ const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
1446
+ const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
1387
1447
  this.#allowUnknownViewportMutationOnNextRender = false;
1388
1448
 
1389
1449
  // 3. Classify intent.
1390
- const intent = this.#planRender(
1450
+ let intent = this.#planRender(
1391
1451
  lines,
1392
1452
  widthChanged,
1393
1453
  heightChanged,
@@ -1396,7 +1456,62 @@ export class TUI extends Container {
1396
1456
  visibleOverlayComponents.length > 0,
1397
1457
  overlayVisibilityReduced,
1398
1458
  allowUnknownViewportMutation,
1459
+ this.#nativeScrollbackLiveRegionStart,
1399
1460
  );
1461
+ // 3b. Defer scrollback commits during foreground streaming, but only on
1462
+ // ED3-risk terminals whose committed scrollback cannot be rewritten without
1463
+ // yanking a scrolled reader. There the eager rebuild is gated off and the
1464
+ // diff emitter would otherwise `\r\n`-scroll every transient frame (spinner
1465
+ // ticks, partial output) into native history. Non-ED3-risk terminals keep
1466
+ // their eager live rebuild, which already commits cleanly. Explicit
1467
+ // reconciles — the prompt-submit checkpoint (`clearScrollbackOnNextRender`),
1468
+ // user-input/IME opt-ins (`explicitViewportMutation`), and overlay visibility
1469
+ // reductions that must scrub transient overlay cells from native history —
1470
+ // are never deferred: the triggering interaction pins the host to the bottom.
1471
+ const streamingWasActive = this.#eagerNativeScrollbackRebuild;
1472
+ if (streamingWasActive && !this.#previousStreamingActive) {
1473
+ this.#streamingHighWater = 0;
1474
+ }
1475
+ this.#previousStreamingActive = streamingWasActive;
1476
+ if (streamingWasActive && eagerEraseScrollbackRisk) {
1477
+ const streamingActive =
1478
+ this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
1479
+ const explicitReconcile =
1480
+ explicitViewportMutation || this.#clearScrollbackOnNextRender || overlayVisibilityReduced;
1481
+ // The defer below exists only to avoid `\r\n`-scrolling transient frames
1482
+ // past a reader parked in native scrollback. When the terminal can report
1483
+ // that the viewport is at the tail, there is no scrolled reader to yank,
1484
+ // so the planned intent must stand and commit normally — otherwise a row
1485
+ // that scrolls above the viewport top is dropped (neither pushed to
1486
+ // history nor kept in the capped viewport). Production POSIX ED3-risk
1487
+ // terminals cannot report this and stay `undefined`, so they still defer.
1488
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1489
+ if (!streamingActive) {
1490
+ // Streaming just ended. Keep native scrollback dirty so the next
1491
+ // checkpoint reconciles the settled transcript; never erase here.
1492
+ this.#streamingHighWater = 0;
1493
+ this.#markNativeScrollbackDirty();
1494
+ } else if (
1495
+ !explicitReconcile &&
1496
+ nativeViewportAtBottom !== true &&
1497
+ (intent.kind === "sessionReplace" ||
1498
+ intent.kind === "historyRebuild" ||
1499
+ intent.kind === "overlayRebuild" ||
1500
+ (intent.kind === "diff" && intent.appendedLines))
1501
+ ) {
1502
+ // Cap the frame to the viewport and keep scrollback dirty: transient
1503
+ // rows never enter history, and the checkpoint reconciles later.
1504
+ this.#markNativeScrollbackDirty();
1505
+ this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
1506
+ this.#scrollbackHighWater = 0;
1507
+ lines = lines.slice(-height);
1508
+ intent = { kind: "viewportRepaint" };
1509
+ } else {
1510
+ // Explicit reconcile or a non-committing frame (noop): let the
1511
+ // planned intent stand, but keep tracking the streaming peak.
1512
+ this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
1513
+ }
1514
+ }
1400
1515
  if (this.#eagerNativeScrollbackRebuildDisablePending) {
1401
1516
  this.#eagerNativeScrollbackRebuildDisablePending = false;
1402
1517
  this.#eagerNativeScrollbackRebuild = false;
@@ -1448,6 +1563,19 @@ export class TUI extends Container {
1448
1563
  });
1449
1564
  this.#emitViewportRepaint(lines, width, height, cursorPos);
1450
1565
  return;
1566
+ case "liveRegionPinned":
1567
+ this.#emitLiveRegionPinnedRepaint(
1568
+ lines,
1569
+ width,
1570
+ height,
1571
+ cursorPos,
1572
+ intent.appendFrom,
1573
+ intent.appendTo,
1574
+ intent.renderViewportTop,
1575
+ prevViewportTop,
1576
+ prevHardwareCursorRow,
1577
+ );
1578
+ return;
1451
1579
  case "viewportRepaint":
1452
1580
  if (intent.appendFrom !== undefined) {
1453
1581
  this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
@@ -1500,6 +1628,7 @@ export class TUI extends Container {
1500
1628
  hasVisibleOverlay: boolean,
1501
1629
  overlayVisibilityReduced: boolean,
1502
1630
  allowUnknownViewportMutation: boolean,
1631
+ liveRegionStart: number | undefined,
1503
1632
  ): RenderIntent {
1504
1633
  // Initial paint after start(): scrollback must keep its prior shell
1505
1634
  // content, but the viewport must be cleared so stale rows do not bleed
@@ -1532,6 +1661,29 @@ export class TUI extends Container {
1532
1661
  return { kind: "viewportRepaint" };
1533
1662
  }
1534
1663
 
1664
+ const liveRegionPinnedIntent = this.#planLiveRegionPinnedRender(
1665
+ newLines,
1666
+ height,
1667
+ liveRegionStart,
1668
+ eagerEraseScrollbackRisk,
1669
+ allowUnknownViewportMutation,
1670
+ widthChanged || heightChanged,
1671
+ );
1672
+ if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
1673
+
1674
+ // After foreground tool streaming: when content finally shrinks from the
1675
+ // streaming peak, rebuild with ED 3 to commit the settled state cleanly.
1676
+ // The check uses `#streamingHighWater` (the real peak) rather than
1677
+ // `#previousLines.length` because unpinned ED3-risk streaming frames may
1678
+ // commit only a viewport slice while native history is deferred.
1679
+ if (this.#streamingHighWater > height && newLines.length < this.#streamingHighWater && newLines.length > height) {
1680
+ this.#streamingHighWater = 0;
1681
+ return { kind: "historyRebuild" };
1682
+ }
1683
+ if (this.#streamingHighWater > 0 && newLines.length <= height) {
1684
+ this.#streamingHighWater = 0;
1685
+ }
1686
+
1535
1687
  if (
1536
1688
  this.#nativeScrollbackDirty &&
1537
1689
  !isMultiplexerSession() &&
@@ -1584,17 +1736,12 @@ export class TUI extends Container {
1584
1736
  // stale rows until the next input even though the frame has a fresh bottom
1585
1737
  // viewport to show (issues #1682, foreground-stream fidelity on collapse).
1586
1738
  // Native history stays dirty and reconciles at the next checkpoint. With no
1587
- // active eager turn the reader may be scrolled, so defer rather than
1588
- // repainting over their history.
1739
+ // active eager turn the reader may be scrolled; even a padded shrink repaint
1740
+ // can move ED3-risk unknown host scrollback (WSL/Ghostty-style), so defer
1741
+ // completely rather than repainting over their history.
1589
1742
  if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
1590
1743
  this.#markNativeScrollbackDirty();
1591
- if (this.#eagerNativeScrollbackRebuild) {
1592
- return { kind: "viewportRepaint" };
1593
- }
1594
- if (newLines.length <= paddedViewportTop) {
1595
- return { kind: "deferredMutation" };
1596
- }
1597
- return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1744
+ return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
1598
1745
  }
1599
1746
 
1600
1747
  // Non-ED3-risk POSIX with an unobservable viewport. If the shrink still
@@ -1666,6 +1813,8 @@ export class TUI extends Container {
1666
1813
  this.#suppressNextSuffixScroll = false;
1667
1814
  if (
1668
1815
  suppressSuffixScroll &&
1816
+ !widthChanged &&
1817
+ !heightChanged &&
1669
1818
  diff.appendedLines &&
1670
1819
  diff.firstChanged < this.#previousLines.length &&
1671
1820
  !isMultiplexerSession()
@@ -1760,7 +1909,10 @@ export class TUI extends Container {
1760
1909
  return { kind: "viewportRepaint" };
1761
1910
  }
1762
1911
  }
1763
- if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1912
+ // Geometry changes invalidate the terminal's cursor and viewport anchors;
1913
+ // even if the same coalesced frame also edits offscreen content for a scrolled
1914
+ // reader, the resize-specific branch below must repaint/clamp at the new size.
1915
+ if (!pureAppend && structuralMutation && !heightChanged && !widthChanged && !isMultiplexerSession()) {
1764
1916
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1765
1917
  if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1766
1918
  this.#markNativeScrollbackDirty();
@@ -2036,6 +2188,56 @@ export class TUI extends Container {
2036
2188
  );
2037
2189
  }
2038
2190
 
2191
+ #planLiveRegionPinnedRender(
2192
+ newLines: string[],
2193
+ height: number,
2194
+ liveRegionStart: number | undefined,
2195
+ eagerEraseScrollbackRisk: boolean,
2196
+ allowUnknownViewportMutation: boolean,
2197
+ geometryChanged: boolean,
2198
+ ): RenderIntent | undefined {
2199
+ // A width/height change reflows the whole terminal: the relative cursor
2200
+ // positioning this emitter relies on is computed from the pre-resize
2201
+ // geometry and would land on the wrong rows. Defer to the geometry branch
2202
+ // (a full reflow rebuild), which is the established behavior for resizes.
2203
+ if (
2204
+ liveRegionStart === undefined ||
2205
+ liveRegionStart >= newLines.length ||
2206
+ !this.#eagerNativeScrollbackRebuild ||
2207
+ !eagerEraseScrollbackRisk ||
2208
+ allowUnknownViewportMutation ||
2209
+ geometryChanged ||
2210
+ isMultiplexerSession()
2211
+ ) {
2212
+ return undefined;
2213
+ }
2214
+ if (newLines.length <= height && this.#scrollbackHighWater === 0) return undefined;
2215
+ if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
2216
+
2217
+ this.#markNativeScrollbackDirty();
2218
+ const naturalViewportTop = Math.max(0, newLines.length - height);
2219
+ // Rows before the live-region boundary are sealed. If a live-region
2220
+ // collapse moves the bottom-anchored viewport back across rows already
2221
+ // written to native scrollback, repainting those sealed rows duplicates
2222
+ // them in history. Clamp only to the committed sealed boundary: mutable
2223
+ // rows inside the live region must remain visible even when an earlier
2224
+ // taller live frame pushed their old contents into native scrollback. The
2225
+ // dirty checkpoint later reconciles those stale mutable saved lines.
2226
+ const committedSealedEnd = Math.min(this.#scrollbackHighWater, liveRegionStart);
2227
+ const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
2228
+ // Every row above the natural viewport top has physically scrolled out of
2229
+ // the live viewport, so the terminal has already pushed it into native
2230
+ // scrollback — there is nowhere else for an off-screen row to live. It must
2231
+ // therefore be committed as real content, *including the head of the live
2232
+ // block itself* when that block alone overflows the viewport (a tall tool
2233
+ // result, a long streamed reply). Only the live tail that remains *within*
2234
+ // the natural viewport stays transient (repainted in place, deferred to the
2235
+ // checkpoint rebuild).
2236
+ const appendTo = naturalViewportTop;
2237
+ const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
2238
+ return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
2239
+ }
2240
+
2039
2241
  #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
2040
2242
  if (lines.length >= paddedLength) return lines;
2041
2243
  return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
@@ -2208,6 +2410,76 @@ export class TUI extends Container {
2208
2410
  this.#commit(lines, width, height, viewportTop, toRow);
2209
2411
  }
2210
2412
 
2413
+ /**
2414
+ * Foreground-stream live-region paint for ED3-risk terminals with an
2415
+ * unobservable viewport. Commits the newly-sealed chunk to native scrollback
2416
+ * (so finished blocks stay scrollable) and repaints the live tail in place,
2417
+ * leaving the transient live region out of saved lines.
2418
+ *
2419
+ * Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
2420
+ * cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
2421
+ * history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
2422
+ * cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
2423
+ * back to the bottom on every frame.
2424
+ */
2425
+ #emitLiveRegionPinnedRepaint(
2426
+ lines: string[],
2427
+ width: number,
2428
+ height: number,
2429
+ cursorPos: { row: number; col: number } | null,
2430
+ appendFrom: number,
2431
+ appendTo: number,
2432
+ renderViewportTop: number,
2433
+ prevViewportTop: number,
2434
+ prevHardwareCursorRow: number,
2435
+ ): void {
2436
+ this.#fullRedrawCount += 1;
2437
+ const naturalViewportTop = Math.max(0, lines.length - height);
2438
+ const viewportTop = Math.max(0, Math.min(renderViewportTop, lines.length));
2439
+ const boundedAppendTo = Math.max(0, Math.min(appendTo, naturalViewportTop, lines.length));
2440
+ const boundedAppendFrom = Math.max(0, Math.min(appendFrom, boundedAppendTo));
2441
+
2442
+ // Position at the top visible row with a relative move. Terminals clamp the
2443
+ // hardware cursor to the viewport on resize, so clamp our tracking to match
2444
+ // before computing the delta (mirrors #emitDiff).
2445
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
2446
+ const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
2447
+ let buffer = this.#paintBeginSequence;
2448
+ if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
2449
+ buffer += "\r";
2450
+
2451
+ // Write the sealed chunk followed by the full viewport from the top row.
2452
+ // The first (boundedAppendTo - boundedAppendFrom) rows scroll into native
2453
+ // history; the trailing `height` rows fill the viewport. Each row clears
2454
+ // itself with `\x1b[2K` instead of relying on a screen-wide erase.
2455
+ let wroteLine = false;
2456
+ for (let i = boundedAppendFrom; i < boundedAppendTo; i++) {
2457
+ if (wroteLine) buffer += "\r\n";
2458
+ buffer += `\x1b[2K${this.#fitLineToWidth(lines[i] ?? "", width)}`;
2459
+ wroteLine = true;
2460
+ }
2461
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2462
+ if (wroteLine) buffer += "\r\n";
2463
+ buffer += `\x1b[2K${this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width)}`;
2464
+ wroteLine = true;
2465
+ }
2466
+
2467
+ const viewportBottomRow = viewportTop + height - 1;
2468
+ const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
2469
+ const parkUp = viewportBottomRow - contentBottomRow;
2470
+ if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
2471
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
2472
+ buffer += seq;
2473
+ buffer += this.#paintEndSequence;
2474
+ this.terminal.write(buffer);
2475
+
2476
+ this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
2477
+ if (boundedAppendTo > this.#scrollbackHighWater) {
2478
+ this.#scrollbackHighWater = boundedAppendTo;
2479
+ }
2480
+ this.#commit(lines, width, height, viewportTop, toRow);
2481
+ }
2482
+
2211
2483
  /**
2212
2484
  * Push the appended tail into terminal scrollback by `\r\n`-ing past the
2213
2485
  * previous viewport bottom. Used as a prefix to {@link #emitViewportRepaint}
@@ -2443,9 +2715,11 @@ export class TUI extends Container {
2443
2715
  const detail =
2444
2716
  intent.kind === "diff"
2445
2717
  ? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
2446
- : intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
2447
- ? `${intent.kind}(appendFrom=${intent.appendFrom})`
2448
- : intent.kind;
2718
+ : intent.kind === "liveRegionPinned"
2719
+ ? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
2720
+ : intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
2721
+ ? `${intent.kind}(appendFrom=${intent.appendFrom})`
2722
+ : intent.kind;
2449
2723
  const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
2450
2724
  fs.appendFileSync(getDebugLogPath(), msg);
2451
2725
  }