@oh-my-pi/pi-tui 15.8.0 → 15.8.2

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,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.8.2] - 2026-06-03
6
+
7
+ ### Added
8
+
9
+ - Added `PI_NO_SYNC_OUTPUT=1` to disable DEC 2026 synchronized-output wrappers for terminals whose implementation is buggy or visually worse, while keeping the renderer's autowrap guards active during paints ([#1765](https://github.com/can1357/oh-my-pi/issues/1765)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed terminal resizes that land in the same render frame as streamed output splicing a phantom blank row into native scrollback and offsetting every later row by one. A height shrink (or width change carrying an append) with content overflowing the viewport fell through to the differential emitter, whose scroll math is anchored to the pre-resize viewport top and hardware-cursor row — both invalidated by the terminal's own resize reflow. Geometry-changed frames now rebuild native history when the viewport is at (or possibly at) the bottom, and defer non-destructively for a reader confirmed scrolled into history.
14
+ - Fixed Ghostty/kitty/Alacritty-style ED3-risk terminals freezing the prompt after a deferred shrink; focused keyboard input now uses the same explicit user-input viewport opt-in as autocomplete and can repaint immediately instead of waiting for a resize.
15
+ - Deferred eager live scrollback rebuilds under WSL fronted by Windows Terminal (`WT_SESSION` present in a Linux environment) so foreground streaming no longer emits ED3 (`CSI 3 J`) and yanks a reader scrolled into Windows Terminal's host scrollback; deferred rewrites still reconcile at the next prompt-submit checkpoint ([#1610](https://github.com/can1357/oh-my-pi/issues/1610)).
16
+ - Fixed tmux (and screen/zellij) pane history gaining a complete duplicate copy of the transcript every time a deferred offscreen edit was followed by another render. Multiplexer panes never receive a destructive scrollback clear, so the dirty-scrollback rebuild path only appended the full transcript on top of preserved pane history — repeatedly. Live frames inside multiplexers now keep repainting the viewport and leave history reconciliation to explicit checkpoints, which also removes the O(transcript) write amplification per frame.
17
+ - Fixed tmux pane viewports corrupting and pane history duplicating when a resize coincides with rendering: a resize racing a streamed append reached the stale-anchor diff emitters (phantom rows in the pane), a forced render racing a resize replayed the whole transcript into preserved pane history, and the prompt-submit checkpoint did the same after any deferred offscreen edit. Geometry-changed frames inside multiplexers now repaint the viewport in place, and forced-render geometry replays plus checkpoint replays are disabled there — tmux reflows its own pane grid and its history cannot be cleared, only duplicated.
18
+ - Fixed terminal resize events whose dimensions net out unchanged by render time (rapid SIGWINCH round trips during a window drag, coalesced into one 16ms frame) being invisible to the renderer. The terminal reflows its buffer on every resize event — rows move between the viewport and scrollback and can be evicted at the scrollback cap — so diffing against the pre-resize screen splices blank phantom rows into the viewport. The renderer now tracks the resize event itself, not just the dimension delta, and routes such frames through the geometry-change repaint/rebuild paths.
19
+ - Fixed Termux terminal resizes (screen rotation or software-keyboard toggles) displacing or hiding output after the viewport height changed. Content-bearing resizes were routed to the differential emitter, whose scroll math is anchored to the pre-resize viewport, so appended rows landed too low; pure height changes were treated as no-ops, exposing blank rows that later appends could fill without growing native scrollback. Termux resizes now repaint or rebuild at the new geometry like every other non-multiplexer terminal.
20
+ - Fixed the turn-end teardown frame freezing on ED3-risk terminals (Ghostty/kitty/Alacritty/iTerm2): disabling eager scrollback rebuild now takes effect only after the in-flight frame is classified, so the loader/status removal still paints instead of deferring and leaving a stale spinner until the next keystroke.
21
+ - Fixed non-WT ConPTY terminals on Windows (Tabby, Hyper, VS Code, conhost) clearing scrollback and yanking the viewport to the top whenever streaming output or a prompt-submit rebuild arrived while the user was scrolled up. The kernel32 viewport probe describes the ConPTY pseudo-console buffer — which is pinned to the visible grid, invisible to host-UI scrollback — so it reported "at bottom" no matter where the user had scrolled, and the [#1635](https://github.com/can1357/oh-my-pi/issues/1635) fix only distrusted it under `WT_SESSION`, which Tabby and other ConPTY hosts never set. The probe is now removed entirely: every Windows host is treated as viewport-unobservable, live mutations defer destructive rebuilds (no `\x1b[3J`, no viewport movement), and native scrollback reconciles at the prompt-submit checkpoint where the Enter keystroke has already pinned the host viewport to the bottom ([#1746](https://github.com/can1357/oh-my-pi/issues/1746)).
22
+ - Fixed emoji-presentation symbols (a default-text symbol followed by variation-selector-16 `U+FE0F`, e.g. `⚠️`, `ℹ️`, `❤️`, keycaps) measuring as 1 cell instead of 2 in the native width engine on macOS. The native scanner now keeps `UnicodeWidthStr` as the source of truth for multi-codepoint graphemes and applies only the local macOS Hangul Compatibility Jamo character-width delta, preserving VS16/keycap sequence widths without reintroducing jamo cursor drift.
23
+ - Deferred eager live scrollback rebuilds on macOS Terminal.app and iTerm2 so assistant/tool streaming no longer emits ED3 (`CSI 3 J`) while their native viewport position is unobservable, preserving readers scrolled into terminal history ([#1300](https://github.com/can1357/oh-my-pi/issues/1300)).
24
+ - Fixed width-shrink reflow leaving old-width rows in native history so later appends no longer undercount scrollback growth or duplicate wrapped content.
25
+ - Fixed hiding overlays after terminal reflow so stale dialog rows are scrubbed from native scrollback on non-multiplexer terminals.
26
+
27
+ ### Removed
28
+
29
+ - Removed `shouldTrustNativeViewportProbe` and `ProcessTerminal`'s kernel32 `GetConsoleScreenBufferInfo` viewport probe. No Windows environment can answer "is the user's viewport at the bottom" truthfully — under ConPTY (every modern host) the pseudo-console buffer is pinned to the visible grid so the probe always read "at bottom", and under legacy conhost the window tracks the output cursor rather than the buffer tail so it always read "scrolled up" — so the probe and its trust gate are gone; `ProcessTerminal` no longer implements the optional `Terminal.isNativeViewportAtBottom`.
30
+
31
+ ## [15.8.1] - 2026-06-02
32
+
33
+ ### Fixed
34
+
35
+ - Deferred eager live scrollback rebuilds on VTE terminals so GNOME-style Linux terminals do not flash or erase readable scrollback during streaming ([#1719](https://github.com/can1357/oh-my-pi/issues/1719)).
36
+
5
37
  ## [15.8.0] - 2026-06-02
6
38
 
7
39
  ### Fixed
package/README.md CHANGED
@@ -513,7 +513,7 @@ The TUI uses three rendering strategies:
513
513
  2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
514
514
  3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
515
515
 
516
- All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
516
+ All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering unless `PI_NO_SYNC_OUTPUT=1` is set. The opt-out removes only the DEC 2026 wrapper; paint writes still guard terminal autowrap to avoid pending-wrap cursor artifacts.
517
517
 
518
518
  ## Terminal Interface
519
519
 
@@ -16,7 +16,8 @@ export declare class TerminalInfo {
16
16
  readonly trueColor: boolean;
17
17
  readonly hyperlinks: boolean;
18
18
  readonly notifyProtocol: NotifyProtocol;
19
- constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol);
19
+ readonly eagerEraseScrollbackRisk: boolean;
20
+ constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol, eagerEraseScrollbackRisk?: boolean);
20
21
  isImageLine(line: string): boolean;
21
22
  formatNotification(message: string): string;
22
23
  sendNotification(message: string): void;
@@ -28,6 +29,29 @@ export declare function isNotificationSuppressed(): boolean;
28
29
  * Windows Terminal introduced SIXEL support in preview 1.22.
29
30
  */
30
31
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
32
+ /**
33
+ * Whether eager live-frame native scrollback rebuilds are unsafe when the
34
+ * terminal viewport position is unobservable.
35
+ *
36
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
37
+ * terminals below, ED3 can disturb a reader parked in native scrollback during
38
+ * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
39
+ * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
40
+ * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
41
+ * invalidates a reader's scrollback position during live streaming.
42
+ *
43
+ * Windows Terminal erases its host scrollback on ED3 and repositions the
44
+ * viewport against the shortened buffer, so a scrolled-up reader is yanked.
45
+ * Native win32 is excluded here because the renderer guards it with dedicated
46
+ * platform checks (the viewport position is never observable on Windows — see
47
+ * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
48
+ * platform means the outer host is Windows Terminal fronting a WSL distro (WT
49
+ * propagates the variable into the Linux environment), where the same ED3
50
+ * yank applies. See #1610.
51
+ *
52
+ * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
53
+ */
54
+ export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
31
55
  export declare const TERMINAL_ID: TerminalId;
32
56
  export declare const TERMINAL: TerminalInfo;
33
57
  /**
@@ -30,6 +30,32 @@ export interface Terminal {
30
30
  /**
31
31
  * Returns whether the native terminal viewport is at the scrollback tail when
32
32
  * the host exposes that state. `undefined` means the terminal cannot report it.
33
+ *
34
+ * `ProcessTerminal` deliberately does not implement this — no real terminal
35
+ * can answer it truthfully:
36
+ *
37
+ * - POSIX terminals expose no scrollback-position API at all.
38
+ * - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
39
+ * Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
40
+ * ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
41
+ * pseudo-console buffer. That buffer is pinned to the visible grid —
42
+ * scrollback lives in the host UI, invisible to console APIs
43
+ * (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
44
+ * where the user scrolled. Trusting it let streaming-time rebuilds emit
45
+ * `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
46
+ * (Tabby and other ConPTY hosts). No env var distinguishes these hosts
47
+ * (Tabby sets none), so trust cannot be conditional on the environment.
48
+ * - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
49
+ * buffer, but its window follows the output cursor: a probe comparing
50
+ * `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
51
+ * following live output until all ~9001 buffer rows fill, permanently
52
+ * blocking checkpoint scrollback reconciliation.
53
+ *
54
+ * The renderer treats a missing implementation / `undefined` as "unknown":
55
+ * live mutations defer destructive rebuilds and reconcile native scrollback
56
+ * at explicit checkpoints (prompt submit), where the user's keystroke has
57
+ * already pinned the host viewport to the bottom. Only test terminals
58
+ * (xterm.js-backed) implement this with a real answer.
33
59
  */
34
60
  isNativeViewportAtBottom?(): boolean | undefined;
35
61
  /**
@@ -41,43 +67,6 @@ export interface Terminal {
41
67
  /** The last detected terminal appearance, or undefined if not yet known. */
42
68
  get appearance(): TerminalAppearance | undefined;
43
69
  }
44
- /**
45
- * Whether the native console viewport-position probe should be consulted.
46
- *
47
- * Returns `true` only on native Windows that is *not* fronted by Windows
48
- * Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
49
- * ConPTY pseudo-console — which is always pinned to its tail — and not about
50
- * the user-visible scrollback in modern hosts. Treat any such host as
51
- * unreportable so the renderer falls back to the deferred-rebuild path.
52
- *
53
- * Pure helper for unit testing; the runtime call site reads `$env` /
54
- * `process.platform`. See #1635.
55
- */
56
- export declare function shouldTrustNativeViewportProbe(env?: {
57
- WT_SESSION?: string | undefined;
58
- }, platform?: NodeJS.Platform): boolean;
59
- /**
60
- * Whether eager live-frame native scrollback rebuilds are unsafe for the
61
- * current POSIX terminal when its viewport position is unobservable.
62
- *
63
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
64
- * terminals below, ED3 can disturb a reader parked in native scrollback during
65
- * streaming: kitty/ghostty/alacritty clamp the scroll offset back to the active
66
- * tail when saved lines are erased, and WezTerm is the reported POSIX host for
67
- * #1682. Defer only the eager streaming opt-in on these hosts; direct
68
- * user-input renders and explicit checkpoint rebuilds still pass their own
69
- * `allowUnknownViewportMutation` / `allowUnknownViewport` flags.
70
- *
71
- * Pure helper for unit testing; the runtime call site reads `$env` /
72
- * `process.platform`. See #1682.
73
- */
74
- export declare function terminalHasEagerEraseScrollbackRisk(env?: {
75
- WEZTERM_PANE?: string | undefined;
76
- KITTY_WINDOW_ID?: string | undefined;
77
- GHOSTTY_RESOURCES_DIR?: string | undefined;
78
- ALACRITTY_WINDOW_ID?: string | undefined;
79
- TERM_PROGRAM?: string | undefined;
80
- }, platform?: NodeJS.Platform): boolean;
81
70
  /**
82
71
  * Real terminal using process.stdin/stdout
83
72
  */
@@ -87,20 +76,6 @@ export declare class ProcessTerminal implements Terminal {
87
76
  get appearance(): TerminalAppearance | undefined;
88
77
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
89
78
  start(onInput: (data: string) => void, onResize: () => void): void;
90
- /**
91
- * Returns true when Windows' active console viewport is at the scrollback tail.
92
- * POSIX terminals do not expose native scrollback position through a standard API.
93
- *
94
- * On native Windows running under Windows Terminal (the default modern
95
- * host), the `kernel32` probe answers about the ConPTY pseudo-console — not
96
- * the user-visible WT viewport — so it would always read "at bottom" while
97
- * the user is scrolled up. Return `undefined` there so the renderer falls
98
- * back to the POSIX-style deferred-rebuild path: streaming mutations stay
99
- * non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
100
- * checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
101
- * is already pinned to the bottom by the editor keystroke. See #1635.
102
- */
103
- isNativeViewportAtBottom(): boolean | undefined;
104
79
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
105
80
  stop(): void;
106
81
  write(data: string): void;
@@ -1,4 +1,4 @@
1
- import { type Terminal } from "./terminal";
1
+ import type { Terminal } from "./terminal";
2
2
  import { visibleWidth } from "./utils";
3
3
  type InputListenerResult = {
4
4
  consume?: boolean;
@@ -172,10 +172,19 @@ export declare class TUI extends Container {
172
172
  * duplicate-free history and is meant for windows where output above the fold
173
173
  * is actively re-rendering — e.g. a tool whose result is still streaming and
174
174
  * re-laying-out rows that have already scrolled into history. A terminal that
175
- * can report a *known*-scrolled viewport (Windows) still defers; only the
176
- * unknown case is forced to rebuild. POSIX hosts known to disturb scrolled
177
- * readers on xterm ED3 (`CSI 3 J`, erase saved lines) also defer the eager
178
- * opt-in; checkpoint and direct user-input rebuilds are unaffected.
175
+ * reports a *known*-scrolled viewport still defers, as does native Windows
176
+ * (the viewport is never observable there and ConPTY hosts erase host
177
+ * scrollback on ED3 #1635/#1746); only the unknown POSIX case is forced to
178
+ * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
179
+ * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
180
+ * direct user-input rebuilds are unaffected.
181
+ *
182
+ * Disabling does not take effect until the next frame has been classified:
183
+ * the event batch that ends a foreground stream both removes its UI rows
184
+ * (loader/status teardown — a shrink) and clears this flag before the
185
+ * throttled render timer fires. If the flag dropped immediately, that
186
+ * teardown frame would hit the ED3-risk idle deferral and freeze on screen
187
+ * (stale spinner) until the next keystroke.
179
188
  */
180
189
  setEagerNativeScrollbackRebuild(enabled: boolean): void;
181
190
  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.8.0",
4
+ "version": "15.8.2",
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.8.0",
41
- "@oh-my-pi/pi-utils": "15.8.0",
40
+ "@oh-my-pi/pi-natives": "15.8.2",
41
+ "@oh-my-pi/pi-utils": "15.8.2",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -24,6 +24,7 @@ export class TerminalInfo {
24
24
  public readonly trueColor: boolean,
25
25
  public readonly hyperlinks: boolean,
26
26
  public readonly notifyProtocol: NotifyProtocol = NotifyProtocol.Bell,
27
+ public readonly eagerEraseScrollbackRisk: boolean = false,
27
28
  ) {}
28
29
 
29
30
  isImageLine(line: string): boolean {
@@ -91,6 +92,57 @@ export function isWindowsTerminalPreviewSixelSupported(
91
92
  if (!version) return false;
92
93
  return version.major > 1 || (version.major === 1 && version.minor >= 22);
93
94
  }
95
+
96
+ /**
97
+ * Whether eager live-frame native scrollback rebuilds are unsafe when the
98
+ * terminal viewport position is unobservable.
99
+ *
100
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
101
+ * terminals below, ED3 can disturb a reader parked in native scrollback during
102
+ * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
103
+ * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
104
+ * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
105
+ * invalidates a reader's scrollback position during live streaming.
106
+ *
107
+ * Windows Terminal erases its host scrollback on ED3 and repositions the
108
+ * viewport against the shortened buffer, so a scrolled-up reader is yanked.
109
+ * Native win32 is excluded here because the renderer guards it with dedicated
110
+ * platform checks (the viewport position is never observable on Windows — see
111
+ * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
112
+ * platform means the outer host is Windows Terminal fronting a WSL distro (WT
113
+ * propagates the variable into the Linux environment), where the same ED3
114
+ * yank applies. See #1610.
115
+ *
116
+ * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
117
+ */
118
+ export function detectTerminalEagerEraseScrollbackRisk(
119
+ env: NodeJS.ProcessEnv = Bun.env,
120
+ platform: NodeJS.Platform = process.platform,
121
+ ): boolean {
122
+ if (platform === "win32") return false;
123
+ if (env.WT_SESSION) return true;
124
+ if (
125
+ env.WEZTERM_PANE ||
126
+ env.KITTY_WINDOW_ID ||
127
+ env.GHOSTTY_RESOURCES_DIR ||
128
+ env.ALACRITTY_WINDOW_ID ||
129
+ env.VTE_VERSION ||
130
+ env.ITERM_SESSION_ID
131
+ ) {
132
+ return true;
133
+ }
134
+ switch (env.TERM_PROGRAM?.toLowerCase()) {
135
+ case "alacritty":
136
+ case "apple_terminal":
137
+ case "ghostty":
138
+ case "iterm.app":
139
+ case "kitty":
140
+ case "wezterm":
141
+ return true;
142
+ default:
143
+ return false;
144
+ }
145
+ }
94
146
  function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null {
95
147
  if (!process.stdout.isTTY) return null;
96
148
  if (terminalId === "vscode" || terminalId === "alacritty") return null;
@@ -105,12 +157,12 @@ const KNOWN_TERMINALS = Object.freeze({
105
157
  base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
106
158
  trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
107
159
  // Recognized terminals
108
- kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99),
109
- ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
110
- wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
111
- iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
160
+ kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true),
161
+ ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
162
+ wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
163
+ iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9, true),
112
164
  vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
113
- alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
165
+ alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell, true),
114
166
  });
115
167
 
116
168
  export const TERMINAL_ID: TerminalId = (() => {
@@ -155,26 +207,39 @@ export const TERMINAL_ID: TerminalId = (() => {
155
207
  })();
156
208
 
157
209
  export const TERMINAL = (() => {
158
- const terminal = getTerminalInfo(TERMINAL_ID);
210
+ let resolved = getTerminalInfo(TERMINAL_ID);
211
+ const eagerEraseScrollbackRisk = detectTerminalEagerEraseScrollbackRisk(Bun.env, process.platform);
212
+ if (resolved.eagerEraseScrollbackRisk !== eagerEraseScrollbackRisk) {
213
+ resolved = new TerminalInfo(
214
+ resolved.id,
215
+ resolved.imageProtocol,
216
+ resolved.trueColor,
217
+ resolved.hyperlinks,
218
+ resolved.notifyProtocol,
219
+ eagerEraseScrollbackRisk,
220
+ );
221
+ }
222
+
159
223
  const forcedImageProtocol = getForcedImageProtocol();
160
- let resolved = terminal;
161
224
  if (forcedImageProtocol !== undefined) {
162
225
  resolved = new TerminalInfo(
163
- terminal.id,
226
+ resolved.id,
164
227
  forcedImageProtocol,
165
- terminal.trueColor,
166
- terminal.hyperlinks,
167
- terminal.notifyProtocol,
228
+ resolved.trueColor,
229
+ resolved.hyperlinks,
230
+ resolved.notifyProtocol,
231
+ resolved.eagerEraseScrollbackRisk,
168
232
  );
169
- } else if (!terminal.imageProtocol) {
170
- const fallbackImageProtocol = getFallbackImageProtocol(terminal.id);
233
+ } else if (!resolved.imageProtocol) {
234
+ const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
171
235
  if (fallbackImageProtocol) {
172
236
  resolved = new TerminalInfo(
173
- terminal.id,
237
+ resolved.id,
174
238
  fallbackImageProtocol,
175
- terminal.trueColor,
176
- terminal.hyperlinks,
177
- terminal.notifyProtocol,
239
+ resolved.trueColor,
240
+ resolved.hyperlinks,
241
+ resolved.notifyProtocol,
242
+ resolved.eagerEraseScrollbackRisk,
178
243
  );
179
244
  }
180
245
  }
@@ -188,6 +253,7 @@ export const TERMINAL = (() => {
188
253
  resolved.trueColor,
189
254
  false,
190
255
  resolved.notifyProtocol,
256
+ resolved.eagerEraseScrollbackRisk,
191
257
  );
192
258
  }
193
259
  return resolved;
package/src/terminal.ts CHANGED
@@ -18,7 +18,6 @@ let activeTerminal: ProcessTerminal | null = null;
18
18
  let terminalEverStarted = false;
19
19
 
20
20
  const STD_INPUT_HANDLE = -10;
21
- const STD_OUTPUT_HANDLE = -11;
22
21
  const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
23
22
  /**
24
23
  * Emergency terminal restore - call this from signal/crash handlers
@@ -96,6 +95,32 @@ export interface Terminal {
96
95
  /**
97
96
  * Returns whether the native terminal viewport is at the scrollback tail when
98
97
  * the host exposes that state. `undefined` means the terminal cannot report it.
98
+ *
99
+ * `ProcessTerminal` deliberately does not implement this — no real terminal
100
+ * can answer it truthfully:
101
+ *
102
+ * - POSIX terminals expose no scrollback-position API at all.
103
+ * - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
104
+ * Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
105
+ * ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
106
+ * pseudo-console buffer. That buffer is pinned to the visible grid —
107
+ * scrollback lives in the host UI, invisible to console APIs
108
+ * (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
109
+ * where the user scrolled. Trusting it let streaming-time rebuilds emit
110
+ * `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
111
+ * (Tabby and other ConPTY hosts). No env var distinguishes these hosts
112
+ * (Tabby sets none), so trust cannot be conditional on the environment.
113
+ * - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
114
+ * buffer, but its window follows the output cursor: a probe comparing
115
+ * `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
116
+ * following live output until all ~9001 buffer rows fill, permanently
117
+ * blocking checkpoint scrollback reconciliation.
118
+ *
119
+ * The renderer treats a missing implementation / `undefined` as "unknown":
120
+ * live mutations defer destructive rebuilds and reconcile native scrollback
121
+ * at explicit checkpoints (prompt submit), where the user's keystroke has
122
+ * already pinned the host viewport to the bottom. Only test terminals
123
+ * (xterm.js-backed) implement this with a real answer.
99
124
  */
100
125
  isNativeViewportAtBottom?(): boolean | undefined;
101
126
 
@@ -113,60 +138,6 @@ function isWindowsSubsystemForLinux(): boolean {
113
138
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
114
139
  }
115
140
 
116
- /**
117
- * Whether the native console viewport-position probe should be consulted.
118
- *
119
- * Returns `true` only on native Windows that is *not* fronted by Windows
120
- * Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
121
- * ConPTY pseudo-console — which is always pinned to its tail — and not about
122
- * the user-visible scrollback in modern hosts. Treat any such host as
123
- * unreportable so the renderer falls back to the deferred-rebuild path.
124
- *
125
- * Pure helper for unit testing; the runtime call site reads `$env` /
126
- * `process.platform`. See #1635.
127
- */
128
- export function shouldTrustNativeViewportProbe(
129
- env: { WT_SESSION?: string | undefined } = $env,
130
- platform: NodeJS.Platform = process.platform,
131
- ): boolean {
132
- if (platform !== "win32") return false;
133
- if (env.WT_SESSION) return false;
134
- return true;
135
- }
136
-
137
- /**
138
- * Whether eager live-frame native scrollback rebuilds are unsafe for the
139
- * current POSIX terminal when its viewport position is unobservable.
140
- *
141
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
142
- * terminals below, ED3 can disturb a reader parked in native scrollback during
143
- * streaming: kitty/ghostty/alacritty clamp the scroll offset back to the active
144
- * tail when saved lines are erased, and WezTerm is the reported POSIX host for
145
- * #1682. Defer only the eager streaming opt-in on these hosts; direct
146
- * user-input renders and explicit checkpoint rebuilds still pass their own
147
- * `allowUnknownViewportMutation` / `allowUnknownViewport` flags.
148
- *
149
- * Pure helper for unit testing; the runtime call site reads `$env` /
150
- * `process.platform`. See #1682.
151
- */
152
- export function terminalHasEagerEraseScrollbackRisk(
153
- env: {
154
- WEZTERM_PANE?: string | undefined;
155
- KITTY_WINDOW_ID?: string | undefined;
156
- GHOSTTY_RESOURCES_DIR?: string | undefined;
157
- ALACRITTY_WINDOW_ID?: string | undefined;
158
- TERM_PROGRAM?: string | undefined;
159
- } = $env,
160
- platform: NodeJS.Platform = process.platform,
161
- ): boolean {
162
- if (platform === "win32") return false;
163
- if (env.WEZTERM_PANE || env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR || env.ALACRITTY_WINDOW_ID) {
164
- return true;
165
- }
166
- const termProgram = env.TERM_PROGRAM?.toLowerCase();
167
- return termProgram === "ghostty";
168
- }
169
-
170
141
  /**
171
142
  * Real terminal using process.stdin/stdout
172
143
  */
@@ -265,42 +236,6 @@ export class ProcessTerminal implements Terminal {
265
236
  }
266
237
  }
267
238
 
268
- /**
269
- * Returns true when Windows' active console viewport is at the scrollback tail.
270
- * POSIX terminals do not expose native scrollback position through a standard API.
271
- *
272
- * On native Windows running under Windows Terminal (the default modern
273
- * host), the `kernel32` probe answers about the ConPTY pseudo-console — not
274
- * the user-visible WT viewport — so it would always read "at bottom" while
275
- * the user is scrolled up. Return `undefined` there so the renderer falls
276
- * back to the POSIX-style deferred-rebuild path: streaming mutations stay
277
- * non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
278
- * checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
279
- * is already pinned to the bottom by the editor keystroke. See #1635.
280
- */
281
- isNativeViewportAtBottom(): boolean | undefined {
282
- if (!shouldTrustNativeViewportProbe()) return undefined;
283
- try {
284
- const kernel32 = dlopen("kernel32.dll", {
285
- GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
286
- GetConsoleScreenBufferInfo: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
287
- });
288
- try {
289
- const handle = kernel32.symbols.GetStdHandle(STD_OUTPUT_HANDLE);
290
- const info = new Uint8Array(22);
291
- const infoPtr = ptr(info);
292
- if (!infoPtr || !kernel32.symbols.GetConsoleScreenBufferInfo(handle, infoPtr)) return undefined;
293
- const viewBottom = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(16, true);
294
- const bufferHeight = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(2, true);
295
- return viewBottom >= bufferHeight - 1;
296
- } finally {
297
- kernel32.close();
298
- }
299
- } catch {
300
- return undefined;
301
- }
302
- }
303
-
304
239
  /**
305
240
  * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
306
241
  * so modified keys (for example Shift+Tab) arrive as VT escape sequences.
package/src/tui.ts CHANGED
@@ -6,7 +6,7 @@ import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
7
  import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
8
8
  import { isKeyRelease, matchesKey } from "./keys";
9
- import { type Terminal, terminalHasEagerEraseScrollbackRisk } from "./terminal";
9
+ import type { Terminal } from "./terminal";
10
10
  import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
11
11
  import {
12
12
  Ellipsis,
@@ -31,11 +31,22 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
31
31
  // row under a visible cursor. Paint writes also disable terminal autowrap:
32
32
  // several terminals keep a "pending wrap" flag after an exact-width row, so a
33
33
  // following cursor move can first wrap to the next row and produce staircase
34
- // trails. The TUI emits explicit CRLFs and restores autowrap before leaving
35
- // synchronized output mode.
34
+ // trails. The TUI emits explicit CRLFs and restores autowrap before leaving the
35
+ // paint. Synchronized output can be disabled for terminals with broken DEC 2026
36
+ // implementations; autowrap discipline stays on either way.
36
37
  const HIDE_CURSOR = "\x1b[?25l";
37
- const PAINT_BEGIN = `${HIDE_CURSOR}\x1b[?2026h\x1b[?7l`;
38
- const PAINT_END = "\x1b[?7h\x1b[?2026l";
38
+ const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
39
+ const SYNC_OUTPUT_END = "\x1b[?2026l";
40
+ const DISABLE_AUTOWRAP = "\x1b[?7l";
41
+ const ENABLE_AUTOWRAP = "\x1b[?7h";
42
+ const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
43
+ const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
44
+ const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
45
+ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
46
+ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
47
+ const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
48
+ const CURSOR_END = SYNC_OUTPUT_END;
49
+ const CURSOR_END_NO_SYNC = "";
39
50
 
40
51
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
41
52
  type InputListener = (data: string) => InputListenerResult;
@@ -157,10 +168,6 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
157
168
  return undefined;
158
169
  }
159
170
 
160
- function isTermuxSession(): boolean {
161
- return Boolean(process.env.TERMUX_VERSION);
162
- }
163
-
164
171
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
165
172
  function isMultiplexerSession(): boolean {
166
173
  return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
@@ -316,6 +323,11 @@ export class TUI extends Container {
316
323
  #sixelProbeUnsubscribe?: () => void;
317
324
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
318
325
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
326
+ #synchronizedOutputEnabled = !$flag("PI_NO_SYNC_OUTPUT");
327
+ #paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
328
+ #paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
329
+ #cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
330
+ #cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
319
331
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
320
332
  // Highest count of content rows currently sitting in terminal scrollback
321
333
  // above the visible viewport. Used to detect shrink-across-viewport-boundary
@@ -332,7 +344,20 @@ export class TUI extends Container {
332
344
  #forceViewportRepaintOnNextRender = false;
333
345
  #allowUnknownViewportMutationOnNextRender = false;
334
346
  #eagerNativeScrollbackRebuild = false;
347
+ // Set when eager mode is switched off; applied after the next frame is
348
+ // classified so teardown frames from the same event batch still render
349
+ // eagerly (see setEagerNativeScrollbackRebuild).
350
+ #eagerNativeScrollbackRebuildDisablePending = false;
351
+ #previousVisibleOverlayComponents: Component[] = [];
352
+ #visibleOverlayComponentsThisRender: Component[] = [];
335
353
  #hasEverRendered = false;
354
+ // Set by the terminal resize callback; consumed by the next render. A resize
355
+ // event invalidates the committed screen even when the dimensions net out
356
+ // unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
357
+ // budget): the terminal reflowed its buffer on each event, moving rows
358
+ // between the viewport and scrollback, so the previous frame no longer
359
+ // describes the screen. Tracking only the dimension delta misses this.
360
+ #resizeEventPending = false;
336
361
  #stopped = false;
337
362
 
338
363
  // Overlay stack for modal components rendered on top of base content
@@ -387,13 +412,28 @@ export class TUI extends Container {
387
412
  * duplicate-free history and is meant for windows where output above the fold
388
413
  * is actively re-rendering — e.g. a tool whose result is still streaming and
389
414
  * re-laying-out rows that have already scrolled into history. A terminal that
390
- * can report a *known*-scrolled viewport (Windows) still defers; only the
391
- * unknown case is forced to rebuild. POSIX hosts known to disturb scrolled
392
- * readers on xterm ED3 (`CSI 3 J`, erase saved lines) also defer the eager
393
- * opt-in; checkpoint and direct user-input rebuilds are unaffected.
415
+ * reports a *known*-scrolled viewport still defers, as does native Windows
416
+ * (the viewport is never observable there and ConPTY hosts erase host
417
+ * scrollback on ED3 #1635/#1746); only the unknown POSIX case is forced to
418
+ * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
419
+ * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
420
+ * direct user-input rebuilds are unaffected.
421
+ *
422
+ * Disabling does not take effect until the next frame has been classified:
423
+ * the event batch that ends a foreground stream both removes its UI rows
424
+ * (loader/status teardown — a shrink) and clears this flag before the
425
+ * throttled render timer fires. If the flag dropped immediately, that
426
+ * teardown frame would hit the ED3-risk idle deferral and freeze on screen
427
+ * (stale spinner) until the next keystroke.
394
428
  */
395
429
  setEagerNativeScrollbackRebuild(enabled: boolean): void {
396
- this.#eagerNativeScrollbackRebuild = enabled;
430
+ if (enabled) {
431
+ this.#eagerNativeScrollbackRebuild = true;
432
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
433
+ return;
434
+ }
435
+ if (!this.#eagerNativeScrollbackRebuild) return;
436
+ this.#eagerNativeScrollbackRebuildDisablePending = true;
397
437
  }
398
438
 
399
439
  setFocus(component: Component | null): void {
@@ -496,6 +536,14 @@ export class TUI extends Container {
496
536
  return undefined;
497
537
  }
498
538
 
539
+ #overlayVisibilityReduced(visibleComponents: readonly Component[]): boolean {
540
+ if (this.#previousVisibleOverlayComponents.length === 0) return false;
541
+ for (const component of this.#previousVisibleOverlayComponents) {
542
+ if (!visibleComponents.includes(component)) return true;
543
+ }
544
+ return false;
545
+ }
546
+
499
547
  override invalidate(): void {
500
548
  super.invalidate();
501
549
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
@@ -505,7 +553,10 @@ export class TUI extends Container {
505
553
  this.#stopped = false;
506
554
  this.terminal.start(
507
555
  data => this.#handleInput(data),
508
- () => this.requestRender(),
556
+ () => {
557
+ this.#resizeEventPending = true;
558
+ this.requestRender();
559
+ },
509
560
  );
510
561
  this.terminal.hideCursor();
511
562
  this.#querySixelSupport();
@@ -696,6 +747,14 @@ export class TUI extends Container {
696
747
  */
697
748
  refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
698
749
  if (!this.#nativeScrollbackDirty || this.#stopped) return false;
750
+ // Multiplexer panes preserve their own history and never receive a
751
+ // destructive clear, so a checkpoint "replay" cannot reconcile anything —
752
+ // it would only append a duplicate copy of the transcript to pane
753
+ // history. Drop the dirty flag; there is nothing actionable behind it.
754
+ if (isMultiplexerSession()) {
755
+ this.#clearNativeScrollbackDirty();
756
+ return false;
757
+ }
699
758
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
700
759
  if (
701
760
  !this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, options?.allowUnknownViewport === true)
@@ -734,8 +793,12 @@ export class TUI extends Container {
734
793
  const geometryChanged =
735
794
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
736
795
  (this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
796
+ // A geometry replay rewraps clearable native scrollback at the new size.
797
+ // Inside a multiplexer the pane reflows its own history and a replay only
798
+ // duplicates it, so never promote forced renders to sessionReplace there.
737
799
  const replayGeometry =
738
800
  geometryChanged &&
801
+ !isMultiplexerSession() &&
739
802
  this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
740
803
  this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
741
804
  this.#forceViewportRepaintOnNextRender = true;
@@ -816,7 +879,7 @@ export class TUI extends Container {
816
879
  return;
817
880
  }
818
881
  this.#focusedComponent.handleInput(data);
819
- this.requestRender();
882
+ this.requestRender(false, { allowUnknownViewportMutation: true });
820
883
  }
821
884
  }
822
885
 
@@ -1173,10 +1236,15 @@ export class TUI extends Container {
1173
1236
 
1174
1237
  // 1. Compose the frame.
1175
1238
  let baseLines = this.render(width);
1176
- let lines = baseLines;
1177
- if (this.overlayStack.length > 0) {
1178
- lines = this.#compositeOverlays(baseLines, width, height);
1239
+ const visibleOverlayComponents: Component[] = [];
1240
+ if (this.overlayStack.length > 0 || this.#previousVisibleOverlayComponents.length > 0) {
1241
+ for (const entry of this.overlayStack) {
1242
+ if (this.#isOverlayVisible(entry)) visibleOverlayComponents.push(entry.component);
1243
+ }
1179
1244
  }
1245
+ this.#visibleOverlayComponentsThisRender = visibleOverlayComponents;
1246
+ const overlayVisibilityReduced = this.#overlayVisibilityReduced(visibleOverlayComponents);
1247
+ let lines = visibleOverlayComponents.length > 0 ? this.#compositeOverlays(baseLines, width, height) : baseLines;
1180
1248
  const cursorPos = this.#extractCursorPosition(lines, height);
1181
1249
  lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
1182
1250
  if (lines !== baseLines) {
@@ -1187,9 +1255,17 @@ export class TUI extends Container {
1187
1255
  // 2. Capture transition + pre-render state before any emitter runs.
1188
1256
  const prevViewportTop = this.#viewportTopRow;
1189
1257
  const prevHardwareCursorRow = this.#hardwareCursorRow;
1258
+ const resizeEventOccurred = this.#resizeEventPending;
1259
+ this.#resizeEventPending = false;
1190
1260
  const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
1191
- const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height;
1192
- const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !terminalHasEagerEraseScrollbackRisk();
1261
+ // A resize event with net-unchanged dimensions still reflowed the terminal
1262
+ // buffer; classify it as a height change so the geometry branches repaint
1263
+ // or rebuild instead of diffing against a screen that no longer exists.
1264
+ const heightChanged =
1265
+ (this.#previousHeight > 0 && this.#previousHeight !== height) ||
1266
+ (resizeEventOccurred && this.#previousHeight > 0);
1267
+ const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
1268
+ const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
1193
1269
  const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender || eagerRebuildAllowed;
1194
1270
  this.#allowUnknownViewportMutationOnNextRender = false;
1195
1271
 
@@ -1200,8 +1276,14 @@ export class TUI extends Container {
1200
1276
  heightChanged,
1201
1277
  prevViewportTop,
1202
1278
  height,
1279
+ visibleOverlayComponents.length > 0,
1280
+ overlayVisibilityReduced,
1203
1281
  allowUnknownViewportMutation,
1204
1282
  );
1283
+ if (this.#eagerNativeScrollbackRebuildDisablePending) {
1284
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
1285
+ this.#eagerNativeScrollbackRebuild = false;
1286
+ }
1205
1287
  this.#logRedraw(intent, lines.length, height);
1206
1288
  // 4. Execute.
1207
1289
  switch (intent.kind) {
@@ -1287,6 +1369,8 @@ export class TUI extends Container {
1287
1369
  heightChanged: boolean,
1288
1370
  prevViewportTop: number,
1289
1371
  height: number,
1372
+ hasVisibleOverlay: boolean,
1373
+ overlayVisibilityReduced: boolean,
1290
1374
  allowUnknownViewportMutation: boolean,
1291
1375
  ): RenderIntent {
1292
1376
  // Initial paint after start(): scrollback must keep its prior shell
@@ -1298,10 +1382,20 @@ export class TUI extends Container {
1298
1382
  if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
1299
1383
 
1300
1384
  const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
1301
- if (this.hasOverlay()) {
1385
+ const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
1386
+ if (overlayVisibilityReduced && !isMultiplexerSession()) {
1387
+ return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
1388
+ }
1389
+ if (hasVisibleOverlay) {
1302
1390
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1391
+ // Multiplexer panes never get a destructive scrollback clear
1392
+ // (clearScrollback is forced off inside them), so a dirty-scrollback
1393
+ // "rebuild" would only append a full duplicate copy of the transcript
1394
+ // to pane history on every dirty frame. Keep repainting the viewport
1395
+ // and leave reconciliation to explicit checkpoints.
1303
1396
  if (
1304
1397
  this.#nativeScrollbackDirty &&
1398
+ !isMultiplexerSession() &&
1305
1399
  this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1306
1400
  ) {
1307
1401
  return { kind: "overlayRebuild" };
@@ -1310,7 +1404,11 @@ export class TUI extends Container {
1310
1404
  return { kind: "viewportRepaint" };
1311
1405
  }
1312
1406
 
1313
- if (this.#nativeScrollbackDirty && this.#nativeViewportIsAtBottom(this.#readNativeViewportAtBottom())) {
1407
+ if (
1408
+ this.#nativeScrollbackDirty &&
1409
+ !isMultiplexerSession() &&
1410
+ this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)
1411
+ ) {
1314
1412
  return { kind: "historyRebuild" };
1315
1413
  }
1316
1414
 
@@ -1359,29 +1457,64 @@ export class TUI extends Container {
1359
1457
  return { kind: "viewportRepaint" };
1360
1458
  }
1361
1459
  // The shrunk transcript still overflows the viewport. A plain viewport
1362
- // repaint would re-emit the rows between the new and old viewport tops on top
1363
- // of the copies the terminal already kept in native scrollback; `deferredShrink`
1364
- // pads to the previous row count so no committed row is re-emitted, and the
1365
- // next checkpoint rebuild cleans up.
1366
- //
1367
- // That deferral only carries real content when `newLines.length` reaches the
1368
- // padded viewport top (`previousLines.length - height`) — otherwise every row
1369
- // the padded repaint draws is past the end of `newLines` and renders blank,
1370
- // hiding the prompt until the next checkpoint. This can happen even when
1371
- // `scrollbackHighWater` is far below `previousLines.length - height`, because
1372
- // prior unknown-POSIX viewport repaints commit longer logical frames without
1373
- // moving the native scrollback boundary. For a shrink that large a blank,
1374
- // uninteractable viewport is the greater evil, so yank with `historyRebuild`.
1375
- // Real win32 unknown probes defer as scrolled above and never reach this; the
1376
- // yank only lands on non-win32 hosts whose probe is genuinely unavailable.
1460
+ // repaint can duplicate stale rows in native scrollback, while a destructive
1461
+ // history rebuild (`CSI 3 J`) can yank readers in ED3-risk terminals whose
1462
+ // viewport position is unobservable. Outside foreground-tool streaming,
1463
+ // keep the old visible history frozen and reconcile at the next explicit
1464
+ // checkpoint. During a foreground tool, a literal no-op freezes the live
1465
+ // command/status view; continue with a non-destructive repaint path instead.
1466
+ if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk && !this.#eagerNativeScrollbackRebuild) {
1467
+ this.#markNativeScrollbackDirty();
1468
+ return { kind: "deferredMutation" };
1469
+ }
1470
+
1471
+ // If the shrink still leaves enough rows to cover the previous viewport
1472
+ // top, `deferredShrink` can repaint that stable slice without committing
1473
+ // duplicate rows to native scrollback. When the shrink jumps above that
1474
+ // padded viewport top, `deferredShrink` would draw only blank padding and
1475
+ // hide the live prompt. Ordinary POSIX terminals rebuild history in that
1476
+ // case; ED3-risk foreground-tool frames use a non-destructive viewport
1477
+ // repaint and leave stale scrollback queued for the next checkpoint.
1377
1478
  const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
1378
1479
  if (newLines.length <= paddedViewportTop) {
1480
+ if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
1481
+ this.#markNativeScrollbackDirty();
1482
+ return { kind: "viewportRepaint" };
1483
+ }
1379
1484
  return { kind: "historyRebuild" };
1380
1485
  }
1381
1486
  this.#markNativeScrollbackDirty();
1382
1487
  return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1383
1488
  }
1384
1489
 
1490
+ // Multiplexer panes do not give us a safe native-history rebuild path, but
1491
+ // a shrink can still move the logical viewport upward (for example hiding an
1492
+ // overlay that extended past the base frame). A row-diff from the old
1493
+ // viewport top would only clear the old suffix and leave the newly exposed
1494
+ // base rows stale/blank, so repaint the live viewport in place.
1495
+ if (
1496
+ isMultiplexerSession() &&
1497
+ diff.firstChanged !== -1 &&
1498
+ newLines.length < this.#previousLines.length &&
1499
+ naturalViewportTop !== prevViewportTop
1500
+ ) {
1501
+ return { kind: "viewportRepaint" };
1502
+ }
1503
+
1504
+ // Direct-input shrink can also move the natural viewport upward even when
1505
+ // no stale high-water scrollback is involved (for example slash autocomplete
1506
+ // filtering from many rows to a few). The diff emitter is anchored to the
1507
+ // previous viewport top and would only clear the old suffix, hiding the
1508
+ // editor above the live window.
1509
+ if (
1510
+ allowUnknownViewportMutation &&
1511
+ diff.firstChanged !== -1 &&
1512
+ newLines.length < this.#previousLines.length &&
1513
+ naturalViewportTop !== prevViewportTop
1514
+ ) {
1515
+ return { kind: "viewportRepaint" };
1516
+ }
1517
+
1385
1518
  const suppressSuffixScroll = this.#suppressNextSuffixScroll;
1386
1519
  this.#suppressNextSuffixScroll = false;
1387
1520
  if (
@@ -1418,27 +1551,29 @@ export class TUI extends Container {
1418
1551
  // viewport, but it must keep the existing diff basis so later coalesced
1419
1552
  // content mutations can still update native scrollback correctly.
1420
1553
  if (forceViewportRepaint) return { kind: "viewportRepaint" };
1421
- // Width change still alters wrapping geometry; height change shifts the
1422
- // visible window. Either needs a repaint (outside hostile environments).
1554
+ // Width changes alter wrapping geometry; height changes expose or hide
1555
+ // viewport rows. Repaint any non-multiplexer resize, including Termux
1556
+ // software-keyboard toggles: leaving the new rows blank creates phantom
1557
+ // viewport space that later appends can fill without growing scrollback.
1423
1558
  if (widthChanged) return { kind: "viewportRepaint" };
1424
- if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1559
+ if (heightChanged && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1425
1560
  return { kind: "noop" };
1426
1561
  }
1427
1562
 
1428
- // Width changes rewrap the whole transcript. An offscreen edit leaves
1429
- // native history at the old width, so rebuild it now the terminal already
1430
- // reflowed and the user is at the terminal to resize. Pure appends fall
1431
- // through to the diff path so the append handler scrolls them into history.
1563
+ // Width changes rewrap native history. Any non-append content change must
1564
+ // rebuild the committed transcript when the viewport is safe; a viewport
1565
+ // repaint can leave old-width wrapped fragments above the live frame, and
1566
+ // later appends then splice new rows onto stale history.
1432
1567
  if (widthChanged) {
1433
- if (diff.firstChanged < prevViewportTop) {
1434
- if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
1568
+ const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1569
+ if (!pureAppend) {
1570
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1571
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1435
1572
  this.#markNativeScrollbackDirty();
1436
1573
  return { kind: "viewportRepaint" };
1437
1574
  }
1438
- return { kind: "historyRebuild" };
1575
+ return isMultiplexerSession() ? { kind: "viewportRepaint" } : { kind: "historyRebuild" };
1439
1576
  }
1440
- const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1441
- if (!pureAppend) return { kind: "viewportRepaint" };
1442
1577
  }
1443
1578
 
1444
1579
  const contentGrew = newLines.length > this.#previousLines.length;
@@ -1505,10 +1640,13 @@ export class TUI extends Container {
1505
1640
  }
1506
1641
  }
1507
1642
 
1508
- // Height changes shift the visible window. Repaint when content didn't
1509
- // grow, but skip in Termux (software keyboard toggles height) and inside
1510
- // multiplexers (panes manage their own redraws).
1511
- if (heightChanged && !contentGrew && !isTermuxSession() && !isMultiplexerSession()) {
1643
+ // Height changes shift the visible window. Repaint when content didn't grow,
1644
+ // but skip inside multiplexers (panes manage their own redraws — handled by
1645
+ // the multiplexer geometry branch below). Termux is deliberately included:
1646
+ // a resize with no content change still exposes or hides viewport rows, and
1647
+ // leaving those rows blank lets later appends fill phantom space instead of
1648
+ // growing native scrollback.
1649
+ if (heightChanged && !contentGrew && !isMultiplexerSession()) {
1512
1650
  return { kind: "viewportRepaint" };
1513
1651
  }
1514
1652
 
@@ -1519,7 +1657,47 @@ export class TUI extends Container {
1519
1657
  // tail lands `height`-delta rows too low. With no overflow there is no
1520
1658
  // native scrollback to preserve, so repaint the viewport at the new
1521
1659
  // geometry. (Height changes with overflow keep the existing deferral.)
1522
- if (heightChanged && newLines.length <= height && !isTermuxSession() && !isMultiplexerSession()) {
1660
+ if (heightChanged && newLines.length <= height && !isMultiplexerSession()) {
1661
+ return { kind: "viewportRepaint" };
1662
+ }
1663
+
1664
+ // Any other geometry change (height shrink with content overflowing the
1665
+ // viewport, or a width change carrying a pure append) must not reach the
1666
+ // anchor-relative diff/append emitters below either. The terminal reflowed
1667
+ // its own buffer on resize — a height shrink moves committed rows between
1668
+ // scrollback and viewport — so the previous frame's viewport-top and
1669
+ // hardware-cursor anchors no longer describe the screen, and scrolling
1670
+ // relative to them splices phantom blank rows into native scrollback
1671
+ // (stress repro: darwin-normal-large seed 0x5eed1234 op 1062, a
1672
+ // resizeHeight coalesced with a streamed append). A resize is an explicit
1673
+ // user action, so rebuilding history at the new geometry is the
1674
+ // established tradeoff (see the width-change branch above); a reader
1675
+ // confirmed scrolled into history is still never yanked. Termux is included
1676
+ // (it is not a multiplexer and ED3 clears its own scrollback): a content-
1677
+ // bearing resize must not reach the stale-anchor emitters below.
1678
+ if ((heightChanged || widthChanged) && !isMultiplexerSession()) {
1679
+ // No overflow → nothing of ours in native scrollback to reconcile; an
1680
+ // in-place repaint also keeps preexisting shell scrollback intact.
1681
+ if (newLines.length <= height) {
1682
+ return { kind: "viewportRepaint" };
1683
+ }
1684
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1685
+ if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
1686
+ this.#markNativeScrollbackDirty();
1687
+ return { kind: "viewportRepaint" };
1688
+ }
1689
+ return { kind: "historyRebuild" };
1690
+ }
1691
+
1692
+ // The same geometry hazard inside a multiplexer: tmux reflows the pane
1693
+ // grid (visible rows AND pane history) on resize, so the anchor-relative
1694
+ // diff/append emitters below are equally invalid — but a destructive
1695
+ // rebuild is impossible there (pane history cannot be cleared; a full
1696
+ // replay only appends a duplicate transcript copy). Repaint the visible
1697
+ // window in place at the new geometry. Applies even under Termux: a
1698
+ // repaint per keyboard-toggle resize is cheaper than splicing phantom
1699
+ // rows into the pane.
1700
+ if ((heightChanged || widthChanged) && isMultiplexerSession()) {
1523
1701
  return { kind: "viewportRepaint" };
1524
1702
  }
1525
1703
 
@@ -1635,7 +1813,12 @@ export class TUI extends Container {
1635
1813
  }
1636
1814
 
1637
1815
  #readNativeViewportAtBottom(): boolean | undefined {
1638
- return this.terminal.isNativeViewportAtBottom?.();
1816
+ // A stale positive is destructive: live history rebuilds clear native
1817
+ // scrollback. Require two consecutive at-bottom probes before trusting it.
1818
+ const first = this.terminal.isNativeViewportAtBottom?.();
1819
+ if (first !== true) return first;
1820
+ const second = this.terminal.isNativeViewportAtBottom?.();
1821
+ return second === true ? true : second;
1639
1822
  }
1640
1823
 
1641
1824
  #nativeViewportIsScrolled(
@@ -1652,10 +1835,6 @@ export class TUI extends Container {
1652
1835
  return nativeViewportAtBottom === false;
1653
1836
  }
1654
1837
 
1655
- #nativeViewportIsAtBottom(nativeViewportAtBottom: boolean | undefined): boolean {
1656
- return nativeViewportAtBottom === true;
1657
- }
1658
-
1659
1838
  #canReplayNativeScrollbackAtCheckpoint(
1660
1839
  nativeViewportAtBottom: boolean | undefined,
1661
1840
  allowUnknownViewport: boolean,
@@ -1680,15 +1859,18 @@ export class TUI extends Container {
1680
1859
  * this, every offscreen transcript edit while streaming wiped scrollback and
1681
1860
  * yanked a scrolled-up reader out of their current context.
1682
1861
  * `allowUnknownViewportMutation` (autocomplete/IME) opts directly
1683
- * user-driven frames back into the rebuild. Unlike the checkpoint predicate
1684
- * this carries no `process.platform` optimism resize and checkpoint replays
1685
- * keep using that one.
1862
+ * user-driven POSIX frames back into the rebuild. Native Windows and Windows
1863
+ * Terminal still cannot trust an unknown probe during live rendering — ConPTY
1864
+ * may be fronting host scrollback we cannot observe — so they keep deferring.
1686
1865
  */
1687
1866
  #canRebuildNativeScrollbackLive(
1688
1867
  nativeViewportAtBottom: boolean | undefined,
1689
1868
  allowUnknownViewportMutation: boolean,
1690
1869
  ): boolean {
1691
- return nativeViewportAtBottom === true || (nativeViewportAtBottom === undefined && allowUnknownViewportMutation);
1870
+ return (
1871
+ nativeViewportAtBottom === true ||
1872
+ (nativeViewportAtBottom === undefined && allowUnknownViewportMutation && process.platform !== "win32")
1873
+ );
1692
1874
  }
1693
1875
 
1694
1876
  #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
@@ -1720,8 +1902,10 @@ export class TUI extends Container {
1720
1902
  * Single state-transition point. Every emitter calls this exactly once at
1721
1903
  * the end so cursor/viewport/scrollback accounting stays consistent.
1722
1904
  */
1905
+
1723
1906
  #commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
1724
1907
  this.#previousLines = lines;
1908
+ this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
1725
1909
  this.#forceViewportRepaintOnNextRender = false;
1726
1910
  this.#previousWidth = width;
1727
1911
  this.#previousHeight = height;
@@ -1742,7 +1926,7 @@ export class TUI extends Container {
1742
1926
  options: { clearViewport: boolean; clearScrollback: boolean },
1743
1927
  ): void {
1744
1928
  this.#fullRedrawCount += 1;
1745
- let buffer = PAINT_BEGIN;
1929
+ let buffer = this.#paintBeginSequence;
1746
1930
  if (options.clearViewport) {
1747
1931
  buffer += options.clearScrollback ? "\x1b[2J\x1b[H\x1b[3J" : "\x1b[2J\x1b[H";
1748
1932
  }
@@ -1753,7 +1937,7 @@ export class TUI extends Container {
1753
1937
  const finalRow = Math.max(0, lines.length - 1);
1754
1938
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1755
1939
  buffer += seq;
1756
- buffer += PAINT_END;
1940
+ buffer += this.#paintEndSequence;
1757
1941
  this.terminal.write(buffer);
1758
1942
 
1759
1943
  this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
@@ -1780,7 +1964,7 @@ export class TUI extends Container {
1780
1964
  ): void {
1781
1965
  this.#fullRedrawCount += 1;
1782
1966
  const viewportTop = Math.max(0, lines.length - height);
1783
- let buffer = `${PAINT_BEGIN}\x1b[H`;
1967
+ let buffer = `${this.#paintBeginSequence}\x1b[H`;
1784
1968
  for (let screenRow = 0; screenRow < height; screenRow++) {
1785
1969
  if (screenRow > 0) buffer += "\r\n";
1786
1970
  buffer += "\x1b[2K";
@@ -1797,7 +1981,7 @@ export class TUI extends Container {
1797
1981
  const finalRow = viewportTop + height - 1;
1798
1982
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1799
1983
  buffer += seq;
1800
- buffer += PAINT_END;
1984
+ buffer += this.#paintEndSequence;
1801
1985
  this.terminal.write(buffer);
1802
1986
 
1803
1987
  this.#maxLinesRendered = lines.length;
@@ -1819,7 +2003,7 @@ export class TUI extends Container {
1819
2003
  prevHardwareCursorRow: number,
1820
2004
  ): void {
1821
2005
  if (start >= lines.length) return;
1822
- let buffer = PAINT_BEGIN;
2006
+ let buffer = this.#paintBeginSequence;
1823
2007
  // Clamp tracked cursor to the visible viewport bottom — terminals clamp
1824
2008
  // on resize, so a prior frame may have committed a row that no longer
1825
2009
  // exists. Without this the scroll math points outside the viewport.
@@ -1831,7 +2015,7 @@ export class TUI extends Container {
1831
2015
  buffer += "\r\n";
1832
2016
  buffer += this.#fitLineToWidth(lines[i], width);
1833
2017
  }
1834
- buffer += PAINT_END;
2018
+ buffer += this.#paintEndSequence;
1835
2019
  this.terminal.write(buffer);
1836
2020
  const pushedNow = Math.max(0, lines.length - height);
1837
2021
  if (pushedNow > this.#scrollbackHighWater) {
@@ -1867,7 +2051,7 @@ export class TUI extends Container {
1867
2051
  const viewportTop = Math.max(0, this.#maxLinesRendered - height);
1868
2052
  const targetRow = Math.max(0, lines.length - 1);
1869
2053
 
1870
- let buffer = PAINT_BEGIN;
2054
+ let buffer = this.#paintBeginSequence;
1871
2055
 
1872
2056
  const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
1873
2057
  const currentScreenRow = clampedCursor - prevViewportTop;
@@ -1892,7 +2076,7 @@ export class TUI extends Container {
1892
2076
 
1893
2077
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
1894
2078
  buffer += seq;
1895
- buffer += PAINT_END;
2079
+ buffer += this.#paintEndSequence;
1896
2080
  this.terminal.write(buffer);
1897
2081
 
1898
2082
  this.#maxLinesRendered = lines.length;
@@ -1926,7 +2110,7 @@ export class TUI extends Container {
1926
2110
  const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1927
2111
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1928
2112
 
1929
- let buffer = PAINT_BEGIN;
2113
+ let buffer = this.#paintBeginSequence;
1930
2114
 
1931
2115
  // Scroll-down branch: target row is past the bottom of the previous
1932
2116
  // viewport (a pure append). Emit `\r\n`s so the terminal pushes the
@@ -1977,7 +2161,7 @@ export class TUI extends Container {
1977
2161
 
1978
2162
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
1979
2163
  buffer += seq;
1980
- buffer += PAINT_END;
2164
+ buffer += this.#paintEndSequence;
1981
2165
 
1982
2166
  this.#writeDiffDebug(
1983
2167
  lines,
@@ -2107,6 +2291,6 @@ export class TUI extends Container {
2107
2291
  }
2108
2292
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2109
2293
  this.#hardwareCursorRow = toRow;
2110
- this.terminal.write(`${HIDE_CURSOR}\x1b[?2026h${seq}\x1b[?2026l`);
2294
+ this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
2111
2295
  }
2112
2296
  }