@oh-my-pi/pi-tui 15.9.3 → 15.9.5

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,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.5] - 2026-06-05
6
+
7
+ ### Changed
8
+
9
+ - Changed terminal resize handling so any width or height change always performs a clean reset + redraw: the renderer now unconditionally clears the viewport and native scrollback (`CSI 2 J` / `CSI 3 J`) and replays the full transcript at the new geometry, replacing the previous matrix of conditional viewport-repaint / history-rebuild / deferred-mutation branches. Multiplexer panes still repaint the visible window in place (pane scrollback cannot be erased), but a resize during active ED3-risk foreground streaming now performs the same clean rebuild rather than downgrading to a non-destructive viewport repaint: the terminal already re-wrapped its saved lines at the old width, so the rebuild must erase them (ED 3) instead of leaving the mis-wrapped history on screen. As a deliberate tradeoff this drops the prior no-overflow and confirmed-scrolled guards on resize: a reader scrolled into history snaps back to the bottom and preexisting shell scrollback above the UI is cleared.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed ED3-risk foreground streaming dropping the scrolled-off head of an append-only live block that alone overflows the viewport (a long streamed assistant reply). The live-region pin again committed native scrollback only up to the live-region start, so once the live block grew past the viewport its earlier rows scrolled above the viewport top but were committed nowhere and repainted nowhere — they vanished, leaving the reply looking like a ~viewport-tall circular buffer. The `NativeScrollbackLiveRegion` seam now also reports an optional append-only `getNativeScrollbackCommitSafeEnd`, and the pinned commit boundary is the deeper of the sealed start and that append-only end: rows in `[liveRegionStart, commitSafeEnd)` above the viewport top commit to scrollback, while volatile live blocks (tool previews that collapse) omit the boundary and keep their mutable rows deferred — preserving the pending-box-above-running-box fix.
14
+
15
+ ## [15.9.4] - 2026-06-05
16
+ ### Added
17
+
18
+ - Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
19
+ - Added `PI_TUI_ED3_SAFE=1` environment override to treat a terminal as non-ED3-risk for eager native scrollback rebuilds on unknown POSIX hosts
20
+
21
+ ### Changed
22
+
23
+ - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
24
+ - Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
25
+
26
+ ### Fixed
27
+
28
+ - Fixed ED3-risk unknown-viewport renders repainting offscreen structural edits over stale native scrollback, which could duplicate or shift rows when async blocks collapsed or middle rows were deleted.
29
+ - Fixed ED3-risk foreground streams committing mutable live-region rows into native scrollback, which could leave a stale `pending` tool box above the `running` box after the preview re-rendered.
30
+ - Fixed TUI shutdown leaving paint-time terminal state and Kitty image data behind by restoring synchronized-output/autowrap modes and purging all transmitted Kitty image ids on stop.
31
+ - Fixed stdin buffering splitting surrogate-pair text into UTF-16 halves and reduced timing sensitivity for incomplete escape sequences.
32
+ - Fixed terminal content not reflowing after a resize on terminals using DEC 2048 in-band resize (kitty/Ghostty/iTerm2/WezTerm). `ProcessTerminal.columns`/`rows` returned the last cached in-band report even after the OS already knew the new size, so a SIGWINCH whose in-band report was dropped or malformed (split past the stdin flush window, `:`-subparameter fields) re-rendered the whole transcript at the stale width. OS resize events now reconcile cached in-band geometry against the live `process.stdout` dimensions, dropping a stale cached value so the next render uses the true size; a valid in-band report still re-seeds pixel sizing.
33
+
5
34
  ## [15.9.3] - 2026-06-05
6
35
 
7
36
  ### Fixed
@@ -1043,4 +1072,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1043
1072
 
1044
1073
  ### Fixed
1045
1074
 
1046
- - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
1075
+ - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
@@ -64,6 +64,8 @@ export declare class ImageBudget {
64
64
  endPass(): boolean;
65
65
  /** Image ids to delete from the terminal this frame; clears the pending set. */
66
66
  takePurgeIds(): readonly number[];
67
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
68
+ takeAllTransmittedIds(): readonly number[];
67
69
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
68
70
  shouldTransmit(imageId: number): boolean;
69
71
  /**
@@ -19,8 +19,8 @@
19
19
  import { EventEmitter } from "events";
20
20
  export type StdinBufferOptions = {
21
21
  /**
22
- * Maximum time to wait for sequence completion (default: 10ms)
23
- * After this time, the buffer is flushed even if incomplete
22
+ * Maximum time to wait for sequence completion (default: 75ms).
23
+ * After this time, a genuinely incomplete escape is flushed.
24
24
  */
25
25
  timeout?: number;
26
26
  };
@@ -36,28 +36,23 @@ export declare function isNotificationSuppressed(): boolean;
36
36
  */
37
37
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
38
38
  /**
39
- * Whether eager live-frame native scrollback rebuilds are unsafe when the
40
- * terminal viewport position is unobservable.
39
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
40
+ * viewport position is unobservable.
41
41
  *
42
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
43
- * terminals below, ED3 can disturb a reader parked in native scrollback during
44
- * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
45
- * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
46
- * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
47
- * invalidates a reader's scrollback position during live streaming.
42
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
43
+ * terminals either clamp a scrolled reader back to the active tail or erase host
44
+ * scrollback when ED3 lands. The important property is not the brand name — it
45
+ * is that an unknown viewport position cannot be proven safe. Environment
46
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
47
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
48
48
  *
49
- * Windows Terminal erases its host scrollback on ED3 and repositions the
50
- * viewport against the shortened buffer, so a scrolled-up reader is yanked.
51
- * Native win32 is excluded here because the renderer guards it with dedicated
52
- * platform checks (the viewport position is never observable on Windows — see
53
- * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
54
- * platform means the outer host is Windows Terminal fronting a WSL distro (WT
55
- * propagates the variable into the Linux environment), where the same ED3
56
- * yank applies. See #1610.
57
- *
58
- * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
49
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
50
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
51
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
59
52
  */
60
53
  export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
55
+ export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, terminalId?: TerminalId): boolean;
61
56
  /**
62
57
  * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
63
58
  * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
@@ -48,9 +48,21 @@ export interface Component {
48
48
  * line index where that suffix begins after each render. TUI treats that suffix
49
49
  * — and every root child rendered below it — as not yet safe to commit to native
50
50
  * scrollback on ED3-risk terminals whose viewport position is unobservable.
51
+ *
52
+ * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
53
+ * inside that live suffix: the line index up to which the live region is
54
+ * append-only (its earlier rows never re-layout, only new rows append at the
55
+ * bottom — a streaming assistant message). Rows in `[liveRegionStart,
56
+ * commitSafeEnd)` that scroll above the viewport are safe to commit to native
57
+ * scrollback even though they are technically live, because they will never
58
+ * change. Without this, a single live block that alone overflows the viewport
59
+ * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
60
+ * live blocks (tool previews that collapse) omit it, so their mutable rows stay
61
+ * deferred. Defaults to `liveRegionStart` when absent.
51
62
  */
52
63
  export interface NativeScrollbackLiveRegion {
53
64
  getNativeScrollbackLiveRegionStart(): number | undefined;
65
+ getNativeScrollbackCommitSafeEnd?(): number | undefined;
54
66
  }
55
67
  /**
56
68
  * Interface for components that can receive focus and display a cursor.
@@ -86,9 +98,8 @@ export interface RenderRequestOptions {
86
98
  */
87
99
  allowUnknownViewportMutation?: boolean;
88
100
  }
89
- /** Options for deferred native scrollback rebuild checkpoints. */
101
+ /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
90
102
  export interface NativeScrollbackRefreshOptions {
91
- /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
92
103
  allowUnknownViewport?: boolean;
93
104
  }
94
105
  /** Type guard to check if a component implements Focusable */
@@ -204,8 +215,8 @@ export declare class TUI extends Container {
204
215
  setClearOnShrink(enabled: boolean): void;
205
216
  /**
206
217
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
207
- * paints. Starts from `PI_NO_SYNC_OUTPUT` and is force-disabled at runtime if
208
- * the terminal reports mode 2026 unsupported via DECRQM.
218
+ * paints. Starts from conservative terminal/env detection and is force-disabled
219
+ * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
209
220
  */
210
221
  get synchronizedOutput(): boolean;
211
222
  /**
@@ -252,6 +263,6 @@ export declare class TUI extends Container {
252
263
  * Callers should only invoke this at checkpoints where the user is expected to be
253
264
  * at the terminal bottom, such as after submitting a new prompt.
254
265
  */
255
- refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean;
266
+ refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
256
267
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
257
268
  }
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.3",
4
+ "version": "15.9.5",
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.3",
41
- "@oh-my-pi/pi-utils": "15.9.3",
40
+ "@oh-my-pi/pi-natives": "15.9.5",
41
+ "@oh-my-pi/pi-utils": "15.9.5",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -163,6 +163,16 @@ export class ImageBudget {
163
163
  return ids;
164
164
  }
165
165
 
166
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
167
+ takeAllTransmittedIds(): readonly number[] {
168
+ if (this.#transmitted.size === 0) return EMPTY_IDS;
169
+ const ids = [...this.#transmitted];
170
+ this.#transmitted.clear();
171
+ this.#purgeIds = [];
172
+ this.#pendingTransmits = [];
173
+ return ids;
174
+ }
175
+
166
176
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
167
177
  shouldTransmit(imageId: number): boolean {
168
178
  return !this.#transmitted.has(imageId);
@@ -233,9 +233,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
233
233
  return { sequences, remainder: remaining };
234
234
  }
235
235
  } else {
236
- // Not an escape sequence - take a single character
237
- sequences.push(remaining[0]!);
238
- pos++;
236
+ // Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
237
+ const char = Array.from(remaining)[0] ?? "";
238
+ sequences.push(char);
239
+ pos += char.length;
239
240
  }
240
241
  }
241
242
 
@@ -244,8 +245,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
244
245
 
245
246
  export type StdinBufferOptions = {
246
247
  /**
247
- * Maximum time to wait for sequence completion (default: 10ms)
248
- * After this time, the buffer is flushed even if incomplete
248
+ * Maximum time to wait for sequence completion (default: 75ms).
249
+ * After this time, a genuinely incomplete escape is flushed.
249
250
  */
250
251
  timeout?: number;
251
252
  };
@@ -269,7 +270,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
269
270
 
270
271
  constructor(options: StdinBufferOptions = {}) {
271
272
  super();
272
- this.#timeoutMs = options.timeout ?? 10;
273
+ this.#timeoutMs = options.timeout ?? 75;
273
274
  }
274
275
 
275
276
  process(data: string | Buffer): void {
@@ -118,33 +118,44 @@ export function isWindowsTerminalPreviewSixelSupported(
118
118
  }
119
119
 
120
120
  /**
121
- * Whether eager live-frame native scrollback rebuilds are unsafe when the
122
- * terminal viewport position is unobservable.
121
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
122
+ * viewport position is unobservable.
123
123
  *
124
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
125
- * terminals below, ED3 can disturb a reader parked in native scrollback during
126
- * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
127
- * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
128
- * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
129
- * invalidates a reader's scrollback position during live streaming.
124
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
125
+ * terminals either clamp a scrolled reader back to the active tail or erase host
126
+ * scrollback when ED3 lands. The important property is not the brand name — it
127
+ * is that an unknown viewport position cannot be proven safe. Environment
128
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
129
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
130
130
  *
131
- * Windows Terminal erases its host scrollback on ED3 and repositions the
132
- * viewport against the shortened buffer, so a scrolled-up reader is yanked.
133
- * Native win32 is excluded here because the renderer guards it with dedicated
134
- * platform checks (the viewport position is never observable on Windows — see
135
- * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
136
- * platform means the outer host is Windows Terminal fronting a WSL distro (WT
137
- * propagates the variable into the Linux environment), where the same ED3
138
- * yank applies. See #1610.
139
- *
140
- * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
131
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
132
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
133
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
141
134
  */
142
135
  export function detectTerminalEagerEraseScrollbackRisk(
143
136
  env: NodeJS.ProcessEnv = Bun.env,
144
137
  platform: NodeJS.Platform = process.platform,
145
138
  ): boolean {
146
139
  if (platform === "win32") return false;
140
+
141
+ const term = env.TERM?.toLowerCase() ?? "";
142
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
143
+ const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
144
+
145
+ if (env.PI_TUI_ED3_SAFE === "1") return false;
147
146
  if (env.WT_SESSION) return true;
147
+ if (
148
+ env.SSH_CONNECTION ||
149
+ env.SSH_CLIENT ||
150
+ env.SSH_TTY ||
151
+ env.TMUX ||
152
+ env.STY ||
153
+ env.ZELLIJ ||
154
+ term.startsWith("tmux") ||
155
+ term.startsWith("screen")
156
+ ) {
157
+ return true;
158
+ }
148
159
  if (
149
160
  env.WEZTERM_PANE ||
150
161
  env.KITTY_WINDOW_ID ||
@@ -155,17 +166,62 @@ export function detectTerminalEagerEraseScrollbackRisk(
155
166
  ) {
156
167
  return true;
157
168
  }
158
- switch (env.TERM_PROGRAM?.toLowerCase()) {
169
+ switch (termProgram) {
159
170
  case "alacritty":
160
171
  case "apple_terminal":
161
172
  case "ghostty":
173
+ case "gnome-terminal":
162
174
  case "iterm.app":
175
+ case "kgx":
163
176
  case "kitty":
177
+ case "ptyxis":
164
178
  case "wezterm":
179
+ case "xfce4-terminal":
165
180
  return true;
166
181
  default:
182
+ break;
183
+ }
184
+ if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
185
+ // Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
186
+ // for passive ED3 until a positive terminal-specific integration proves safe.
187
+ return true;
188
+ }
189
+
190
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
191
+ export function shouldEnableSynchronizedOutputByDefault(
192
+ env: NodeJS.ProcessEnv = Bun.env,
193
+ platform: NodeJS.Platform = process.platform,
194
+ terminalId: TerminalId = TERMINAL_ID,
195
+ ): boolean {
196
+ if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
197
+ if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
198
+ if (platform === "win32") return false;
199
+
200
+ const term = env.TERM?.toLowerCase() ?? "";
201
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
202
+ if (
203
+ env.SSH_CONNECTION ||
204
+ env.SSH_CLIENT ||
205
+ env.SSH_TTY ||
206
+ env.TMUX ||
207
+ env.STY ||
208
+ env.ZELLIJ ||
209
+ term.startsWith("tmux") ||
210
+ term.startsWith("screen")
211
+ ) {
212
+ return false;
213
+ }
214
+ if (env.VTE_VERSION) return false;
215
+ switch (termProgram) {
216
+ case "gnome-terminal":
217
+ case "kgx":
218
+ case "ptyxis":
219
+ case "xfce4-terminal":
167
220
  return false;
221
+ default:
222
+ break;
168
223
  }
224
+ return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
169
225
  }
170
226
 
171
227
  /**
package/src/terminal.ts CHANGED
@@ -35,7 +35,9 @@ export function emergencyTerminalRestore(): void {
35
35
  // Blind restore only if we know a terminal was started but lost track of it
36
36
  // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
37
37
  process.stdout.write(
38
- "\x1b[?2004l" + // Disable bracketed paste
38
+ "\x1b[?2026l" + // End synchronized output
39
+ "\x1b[?7h" + // Restore autowrap
40
+ "\x1b[?2004l" + // Disable bracketed paste
39
41
  "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
40
42
  "\x1b[?2048l" + // Disable in-band resize notifications
41
43
  "\x1b[<u" + // Pop kitty keyboard protocol
@@ -173,6 +175,7 @@ export class ProcessTerminal implements Terminal {
173
175
  #wasRaw = false;
174
176
  #inputHandler?: (data: string) => void;
175
177
  #resizeHandler?: () => void;
178
+ #stdoutResizeListener?: () => void;
176
179
  #kittyProtocolActive = false;
177
180
  #modifyOtherKeysActive = false;
178
181
  #modifyOtherKeysTimeout?: Timer;
@@ -239,8 +242,14 @@ export class ProcessTerminal implements Terminal {
239
242
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
240
243
  this.#safeWrite("\x1b[?2004h");
241
244
 
242
- // Set up resize handler immediately
243
- process.stdout.on("resize", this.#resizeHandler);
245
+ // Set up resize handler immediately. The OS refreshes process.stdout
246
+ // dimensions before firing `resize`, so it is authoritative for geometry:
247
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
248
+ this.#stdoutResizeListener = () => {
249
+ this.#reconcileInBandGeometryOnResize();
250
+ this.#resizeHandler?.();
251
+ };
252
+ process.stdout.on("resize", this.#stdoutResizeListener);
244
253
 
245
254
  // Refresh terminal dimensions - they may be stale after suspend/resume
246
255
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -845,6 +854,31 @@ export class ProcessTerminal implements Terminal {
845
854
  }
846
855
  }
847
856
 
857
+ /**
858
+ * Reconcile cached in-band geometry with the OS on an OS-level resize.
859
+ *
860
+ * SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
861
+ * `rows` before the `resize` event fires, so they are authoritative for the
862
+ * new cell geometry. A cached DEC 2048 report can be stale: the matching
863
+ * post-resize report may be dropped (split across stdin reads past the flush
864
+ * window) or carry `:`-subparameters the parser skips, leaving the getters
865
+ * pinned to the old size — which freezes the rendered width because the
866
+ * renderer reflows against {@link columns}/{@link rows}, not the live OS
867
+ * value. Drop a cached dimension that disagrees with the live OS value; the
868
+ * terminal's next valid in-band report re-seeds pixel sizing.
869
+ */
870
+ #reconcileInBandGeometryOnResize(): void {
871
+ if (!this.#inBandResizeActive) return;
872
+ const osColumns = process.stdout.columns;
873
+ const osRows = process.stdout.rows;
874
+ if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
875
+ this.#reportedColumns = undefined;
876
+ }
877
+ if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
878
+ this.#reportedRows = undefined;
879
+ }
880
+ }
881
+
848
882
  async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
849
883
  if (this.#kittyProtocolActive) {
850
884
  // Disable Kitty keyboard protocol first so any late key releases
@@ -897,6 +931,10 @@ export class ProcessTerminal implements Terminal {
897
931
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
898
932
  }
899
933
 
934
+ // Leave paint-time terminal modes even if the process exits between the
935
+ // begin/end halves of a frame. Safe no-ops on terminals that ignored them.
936
+ this.#safeWrite("\x1b[?2026l\x1b[?7h");
937
+
900
938
  // Disable bracketed paste mode
901
939
  this.#safeWrite("\x1b[?2004l");
902
940
 
@@ -958,10 +996,11 @@ export class ProcessTerminal implements Terminal {
958
996
  }
959
997
  this.#inputHandler = undefined;
960
998
  this.#appearance = undefined;
961
- if (this.#resizeHandler) {
962
- process.stdout.removeListener("resize", this.#resizeHandler);
963
- this.#resizeHandler = undefined;
999
+ if (this.#stdoutResizeListener) {
1000
+ process.stdout.removeListener("resize", this.#stdoutResizeListener);
1001
+ this.#stdoutResizeListener = undefined;
964
1002
  }
1003
+ this.#resizeHandler = undefined;
965
1004
 
966
1005
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
967
1006
  // re-interpreted after raw mode is disabled. This fixes a race condition
package/src/tui.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  ImageProtocol,
15
15
  setCellDimensions,
16
16
  setTerminalImageProtocol,
17
+ shouldEnableSynchronizedOutputByDefault,
17
18
  TERMINAL,
18
19
  } from "./terminal-capabilities";
19
20
  import {
@@ -123,15 +124,31 @@ export interface Component {
123
124
  * line index where that suffix begins after each render. TUI treats that suffix
124
125
  * — and every root child rendered below it — as not yet safe to commit to native
125
126
  * scrollback on ED3-risk terminals whose viewport position is unobservable.
127
+ *
128
+ * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
129
+ * inside that live suffix: the line index up to which the live region is
130
+ * append-only (its earlier rows never re-layout, only new rows append at the
131
+ * bottom — a streaming assistant message). Rows in `[liveRegionStart,
132
+ * commitSafeEnd)` that scroll above the viewport are safe to commit to native
133
+ * scrollback even though they are technically live, because they will never
134
+ * change. Without this, a single live block that alone overflows the viewport
135
+ * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
136
+ * live blocks (tool previews that collapse) omit it, so their mutable rows stay
137
+ * deferred. Defaults to `liveRegionStart` when absent.
126
138
  */
127
139
  export interface NativeScrollbackLiveRegion {
128
140
  getNativeScrollbackLiveRegionStart(): number | undefined;
141
+ getNativeScrollbackCommitSafeEnd?(): number | undefined;
129
142
  }
130
143
 
131
144
  function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
132
145
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
133
146
  }
134
147
 
148
+ function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
149
+ return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
150
+ }
151
+
135
152
  /**
136
153
  * Interface for components that can receive focus and display a cursor.
137
154
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -168,9 +185,8 @@ export interface RenderRequestOptions {
168
185
  allowUnknownViewportMutation?: boolean;
169
186
  }
170
187
 
171
- /** Options for deferred native scrollback rebuild checkpoints. */
188
+ /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
172
189
  export interface NativeScrollbackRefreshOptions {
173
- /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
174
190
  allowUnknownViewport?: boolean;
175
191
  }
176
192
  /** Type guard to check if a component implements Focusable */
@@ -387,7 +403,7 @@ export class TUI extends Container {
387
403
  #sixelProbeUnsubscribe?: () => void;
388
404
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
389
405
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
390
- #synchronizedOutputEnabled = !$flag("PI_NO_SYNC_OUTPUT");
406
+ #synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
391
407
  #paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
392
408
  #paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
393
409
  #cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
@@ -403,6 +419,7 @@ export class TUI extends Container {
403
419
  // not scroll replayed live chrome (status/editor) into fresh history.
404
420
  #suppressNextSuffixScroll = false;
405
421
  #nativeScrollbackLiveRegionStart: number | undefined;
422
+ #nativeScrollbackCommitSafeEnd: number | undefined;
406
423
  #nativeScrollbackDirty = false;
407
424
  // Highest `#maxLinesRendered` reached during a foreground tool turn while
408
425
  // intermediate frames were prevented from committing to terminal scrollback.
@@ -456,6 +473,7 @@ export class TUI extends Container {
456
473
  override render(width: number): string[] {
457
474
  width = Math.max(1, width);
458
475
  this.#nativeScrollbackLiveRegionStart = undefined;
476
+ this.#nativeScrollbackCommitSafeEnd = undefined;
459
477
  const lines: string[] = [];
460
478
  for (const child of this.children) {
461
479
  const offset = lines.length;
@@ -466,6 +484,13 @@ export class TUI extends Container {
466
484
  ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
467
485
  : childLines.length;
468
486
  this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
487
+ const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
488
+ if (commitSafeEnd !== undefined) {
489
+ const boundedEnd = Number.isFinite(commitSafeEnd)
490
+ ? Math.max(boundedStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
491
+ : childLines.length;
492
+ this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
493
+ }
469
494
  }
470
495
  lines.push(...childLines);
471
496
  }
@@ -525,8 +550,8 @@ export class TUI extends Container {
525
550
 
526
551
  /**
527
552
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
528
- * paints. Starts from `PI_NO_SYNC_OUTPUT` and is force-disabled at runtime if
529
- * the terminal reports mode 2026 unsupported via DECRQM.
553
+ * paints. Starts from conservative terminal/env detection and is force-disabled
554
+ * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
530
555
  */
531
556
  get synchronizedOutput(): boolean {
532
557
  return this.#synchronizedOutputEnabled;
@@ -874,6 +899,11 @@ export class TUI extends Container {
874
899
  }
875
900
 
876
901
  stop(): void {
902
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
903
+ for (const id of this.#imageBudget.takeAllTransmittedIds()) {
904
+ this.terminal.write(encodeKittyDeleteImage(id));
905
+ }
906
+ }
877
907
  this.#clearSixelProbeState();
878
908
  this.#stopped = true;
879
909
  if (this.#renderTimer) {
@@ -908,7 +938,7 @@ export class TUI extends Container {
908
938
  * Callers should only invoke this at checkpoints where the user is expected to be
909
939
  * at the terminal bottom, such as after submitting a new prompt.
910
940
  */
911
- refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
941
+ refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean {
912
942
  if (!this.#nativeScrollbackDirty || this.#stopped) return false;
913
943
  // Multiplexer panes preserve their own history and never receive a
914
944
  // destructive clear, so a checkpoint "replay" cannot reconcile anything —
@@ -918,18 +948,11 @@ export class TUI extends Container {
918
948
  this.#clearNativeScrollbackDirty();
919
949
  return false;
920
950
  }
921
- let nativeViewportAtBottom = this.#readNativeViewportAtBottom();
922
- const allowUnknownViewport = options?.allowUnknownViewport === true;
923
- if (nativeViewportAtBottom === false && allowUnknownViewport) {
924
- const retriedViewportAtBottom = this.#readNativeViewportAtBottom();
925
- if (this.#canReplayNativeScrollbackAtCheckpoint(retriedViewportAtBottom, allowUnknownViewport)) {
926
- nativeViewportAtBottom = retriedViewportAtBottom;
927
- }
928
- }
929
- if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, allowUnknownViewport)) {
951
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
952
+ if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom)) {
930
953
  return false;
931
954
  }
932
- this.#prepareForcedRender(true, allowUnknownViewport);
955
+ this.#prepareForcedRender(true, false);
933
956
  this.#renderRequested = false;
934
957
  this.#lastRenderAt = this.#renderScheduler.now();
935
958
  this.#doRender();
@@ -957,7 +980,7 @@ export class TUI extends Container {
957
980
  this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
958
981
  }
959
982
 
960
- #prepareForcedRender(clearScrollback: boolean, allowUnknownViewportMutation: boolean): void {
983
+ #prepareForcedRender(clearScrollback: boolean, _allowUnknownViewportMutation: boolean): void {
961
984
  const geometryChanged =
962
985
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
963
986
  (this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
@@ -967,7 +990,7 @@ export class TUI extends Container {
967
990
  const replayGeometry =
968
991
  geometryChanged &&
969
992
  !isMultiplexerSession() &&
970
- this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
993
+ this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom());
971
994
  this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
972
995
  this.#forceViewportRepaintOnNextRender = true;
973
996
  if (this.#renderTimer) {
@@ -1457,6 +1480,7 @@ export class TUI extends Container {
1457
1480
  overlayVisibilityReduced,
1458
1481
  allowUnknownViewportMutation,
1459
1482
  this.#nativeScrollbackLiveRegionStart,
1483
+ this.#nativeScrollbackCommitSafeEnd,
1460
1484
  );
1461
1485
  // 3b. Defer scrollback commits during foreground streaming, but only on
1462
1486
  // ED3-risk terminals whose committed scrollback cannot be rewritten without
@@ -1476,8 +1500,18 @@ export class TUI extends Container {
1476
1500
  if (streamingWasActive && eagerEraseScrollbackRisk) {
1477
1501
  const streamingActive =
1478
1502
  this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
1503
+ // A terminal resize reflowed native scrollback at the OLD geometry, so the
1504
+ // saved rows are already mis-wrapped garbage. The planned historyRebuild
1505
+ // must stand and erase them (ED 3) — capping to a viewport repaint would
1506
+ // leave the corrupt history on screen. Like the other reconciles, a resize
1507
+ // is an explicit user action that snaps the host to the bottom, so there is
1508
+ // no scrolled reader to yank.
1509
+ const geometryChanged = widthChanged || heightChanged;
1479
1510
  const explicitReconcile =
1480
- explicitViewportMutation || this.#clearScrollbackOnNextRender || overlayVisibilityReduced;
1511
+ explicitViewportMutation ||
1512
+ this.#clearScrollbackOnNextRender ||
1513
+ overlayVisibilityReduced ||
1514
+ geometryChanged;
1481
1515
  // The defer below exists only to avoid `\r\n`-scrolling transient frames
1482
1516
  // past a reader parked in native scrollback. When the terminal can report
1483
1517
  // that the viewport is at the tail, there is no scrolled reader to yank,
@@ -1536,10 +1570,31 @@ export class TUI extends Container {
1536
1570
  this.#previousWidth = width;
1537
1571
  this.#previousHeight = height;
1538
1572
  return;
1539
- case "initial":
1540
- this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
1573
+ case "initial": {
1574
+ const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
1575
+ if (
1576
+ this.#eagerNativeScrollbackRebuild &&
1577
+ eagerEraseScrollbackRisk &&
1578
+ !allowUnknownViewportMutation &&
1579
+ liveRegionStart !== undefined &&
1580
+ liveRegionStart < lines.length &&
1581
+ !isMultiplexerSession() &&
1582
+ this.#readNativeViewportAtBottom() === undefined
1583
+ ) {
1584
+ this.#emitInitialLiveRegionPinnedPaint(
1585
+ lines,
1586
+ width,
1587
+ height,
1588
+ cursorPos,
1589
+ liveRegionStart,
1590
+ this.#nativeScrollbackCommitSafeEnd,
1591
+ );
1592
+ } else {
1593
+ this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
1594
+ }
1541
1595
  this.#hasEverRendered = true;
1542
1596
  return;
1597
+ }
1543
1598
  case "sessionReplace":
1544
1599
  this.#clearScrollbackOnNextRender = false;
1545
1600
  this.#clearNativeScrollbackDirty();
@@ -1613,11 +1668,11 @@ export class TUI extends Container {
1613
1668
 
1614
1669
  /**
1615
1670
  * Map the current frame onto a single render intent. Order matters: forced
1616
- * resets and session replacement short-circuit before any diff work. A real
1617
- * resize (geometry change) that invalidates native scrollback rebuilds it now;
1618
- * a pure content mutation that does the same marks scrollback dirty and
1619
- * repaints only the viewport, deferring the destructive clear+replay to an
1620
- * explicit checkpoint so users scrolled into history are not yanked.
1671
+ * resets and session replacement short-circuit first, then a terminal resize
1672
+ * (width or height change) always reduces to a clean reset + redraw at the new
1673
+ * geometry `historyRebuild` normally, `viewportRepaint` inside a multiplexer
1674
+ * whose pane scrollback cannot be erased. Pure content mutations fall through
1675
+ * to the differential machinery below.
1621
1676
  */
1622
1677
  #planRender(
1623
1678
  newLines: string[],
@@ -1629,6 +1684,7 @@ export class TUI extends Container {
1629
1684
  overlayVisibilityReduced: boolean,
1630
1685
  allowUnknownViewportMutation: boolean,
1631
1686
  liveRegionStart: number | undefined,
1687
+ commitSafeEnd: number | undefined,
1632
1688
  ): RenderIntent {
1633
1689
  // Initial paint after start(): scrollback must keep its prior shell
1634
1690
  // content, but the viewport must be cleared so stale rows do not bleed
@@ -1643,6 +1699,25 @@ export class TUI extends Container {
1643
1699
  if (overlayVisibilityReduced && !isMultiplexerSession()) {
1644
1700
  return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
1645
1701
  }
1702
+
1703
+ // A terminal resize (width or height change) reflows the terminal's own
1704
+ // buffer, moving rows between the viewport and native scrollback and
1705
+ // invalidating every cursor/viewport anchor the diff and append emitters
1706
+ // rely on. Always reset cleanly at the new geometry and redraw. Inside a
1707
+ // multiplexer the pane's saved lines cannot be erased (ED 3 is a no-op there
1708
+ // and a full replay only duplicates the transcript), so repaint the visible
1709
+ // window in place; a visible overlay rebuilds with its composite. This
1710
+ // deliberately drops the no-overflow and confirmed-scrolled guards — a
1711
+ // resize is an explicit user action, so a scrolled reader snaps to the
1712
+ // bottom and preexisting shell scrollback above the UI is cleared. The
1713
+ // streaming cap above explicitly exempts geometry changes, so even during
1714
+ // active ED3-risk foreground streaming this rebuild stands and erases the
1715
+ // scrollback the terminal just re-wrapped at the old size.
1716
+ if (widthChanged || heightChanged) {
1717
+ if (isMultiplexerSession()) return { kind: "viewportRepaint" };
1718
+ return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
1719
+ }
1720
+
1646
1721
  if (hasVisibleOverlay) {
1647
1722
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1648
1723
  // Multiplexer panes never get a destructive scrollback clear
@@ -1665,9 +1740,9 @@ export class TUI extends Container {
1665
1740
  newLines,
1666
1741
  height,
1667
1742
  liveRegionStart,
1743
+ commitSafeEnd,
1668
1744
  eagerEraseScrollbackRisk,
1669
1745
  allowUnknownViewportMutation,
1670
- widthChanged || heightChanged,
1671
1746
  );
1672
1747
  if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
1673
1748
 
@@ -1712,14 +1787,11 @@ export class TUI extends Container {
1712
1787
  this.#markNativeScrollbackDirty();
1713
1788
  return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1714
1789
  }
1715
- // A width change rewraps the whole transcript, so committed scrollback is
1716
- // mis-wrapped at the old width. Yank is acceptable on an explicit resize, so
1717
- // rebuild even when the viewport position is unknown (POSIX); the
1718
- // known-scrolled case already deferred above.
1719
- if (
1720
- widthChanged ||
1721
- this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1722
- ) {
1790
+ // A shrink that re-exposes rows already committed to native scrollback
1791
+ // must rebuild so the stale committed copy is cleared. Rebuild only with a
1792
+ // positive at-tail proof; unknown viewports stay dirty because the host
1793
+ // scroll position is not observable and ED3 can yank readers.
1794
+ if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)) {
1723
1795
  return { kind: "historyRebuild" };
1724
1796
  }
1725
1797
  // POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
@@ -1744,16 +1816,17 @@ export class TUI extends Container {
1744
1816
  return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
1745
1817
  }
1746
1818
 
1747
- // Non-ED3-risk POSIX with an unobservable viewport. If the shrink still
1748
- // leaves enough rows to cover the previous viewport top, `deferredShrink`
1749
- // can repaint that stable slice without committing duplicate rows to
1750
- // native scrollback. When the shrink jumps above that padded viewport
1751
- // top, `deferredShrink` would draw only blank padding and hide the live
1752
- // prompt, so rebuild history instead (ED3 is safe on these terminals).
1819
+ // Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
1820
+ // safe only when changed rows are at or below the previous viewport top.
1821
+ // Middle/offscreen deletes renumber rows above the viewport and padding
1822
+ // the old length would repaint shifted rows or blank tail cells.
1753
1823
  if (newLines.length <= paddedViewportTop) {
1754
1824
  return { kind: "historyRebuild" };
1755
1825
  }
1756
1826
  this.#markNativeScrollbackDirty();
1827
+ if (diff.firstChanged < prevViewportTop) {
1828
+ return { kind: "deferredMutation" };
1829
+ }
1757
1830
  return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1758
1831
  }
1759
1832
 
@@ -1813,8 +1886,6 @@ export class TUI extends Container {
1813
1886
  this.#suppressNextSuffixScroll = false;
1814
1887
  if (
1815
1888
  suppressSuffixScroll &&
1816
- !widthChanged &&
1817
- !heightChanged &&
1818
1889
  diff.appendedLines &&
1819
1890
  diff.firstChanged < this.#previousLines.length &&
1820
1891
  !isMultiplexerSession()
@@ -1843,51 +1914,13 @@ export class TUI extends Container {
1843
1914
  }
1844
1915
 
1845
1916
  if (diff.firstChanged === -1) {
1846
- // A geometry change reflows the terminal's own buffer, moving rows between
1847
- // the viewport and native scrollback. When content overflows and the
1848
- // viewport position is unobservable (POSIX/ED3-risk/Windows), an in-place
1849
- // repaint can leave native history out of sync with the transcript, and —
1850
- // unlike a content-bearing resize, which rebuilds via the geometry branch
1851
- // below — nothing flags it. Mark scrollback dirty so the next checkpoint
1852
- // (refreshNativeScrollbackIfDirty) reconciles it; a known-at-bottom reader
1853
- // rebuilds unconditionally at its checkpoint and needs no flag.
1854
- if (
1855
- (widthChanged || heightChanged) &&
1856
- !isMultiplexerSession() &&
1857
- newLines.length > height &&
1858
- this.#readNativeViewportAtBottom() !== true
1859
- ) {
1860
- this.#markNativeScrollbackDirty();
1861
- }
1862
- // Content unchanged. A forced render still needs to refresh the visible
1863
- // viewport, but it must keep the existing diff basis so later coalesced
1864
- // content mutations can still update native scrollback correctly.
1917
+ // Content unchanged. A forced render still refreshes the visible viewport
1918
+ // but keeps the existing diff basis so later coalesced content mutations
1919
+ // can still update native scrollback correctly.
1865
1920
  if (forceViewportRepaint) return { kind: "viewportRepaint" };
1866
- // Width changes alter wrapping geometry; height changes expose or hide
1867
- // viewport rows. Repaint any non-multiplexer resize, including Termux
1868
- // software-keyboard toggles: leaving the new rows blank creates phantom
1869
- // viewport space that later appends can fill without growing scrollback.
1870
- if (widthChanged) return { kind: "viewportRepaint" };
1871
- if (heightChanged && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1872
1921
  return { kind: "noop" };
1873
1922
  }
1874
1923
 
1875
- // Width changes rewrap native history. Any non-append content change must
1876
- // rebuild the committed transcript when the viewport is safe; a viewport
1877
- // repaint can leave old-width wrapped fragments above the live frame, and
1878
- // later appends then splice new rows onto stale history.
1879
- if (widthChanged) {
1880
- const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1881
- if (!pureAppend) {
1882
- const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1883
- if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1884
- this.#markNativeScrollbackDirty();
1885
- return { kind: "viewportRepaint" };
1886
- }
1887
- return isMultiplexerSession() ? { kind: "viewportRepaint" } : { kind: "historyRebuild" };
1888
- }
1889
- }
1890
-
1891
1924
  const contentGrew = newLines.length > this.#previousLines.length;
1892
1925
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1893
1926
  const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
@@ -1909,10 +1942,11 @@ export class TUI extends Container {
1909
1942
  return { kind: "viewportRepaint" };
1910
1943
  }
1911
1944
  }
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()) {
1945
+ // A structural mutation (offscreen edit or inserted rows) while bottom-
1946
+ // anchored: when the reader is scrolled, repaint/clamp without trusting the
1947
+ // stale viewport anchors; otherwise rebuild native history when a safe
1948
+ // checkpoint allows.
1949
+ if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1916
1950
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1917
1951
  if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1918
1952
  this.#markNativeScrollbackDirty();
@@ -1955,67 +1989,6 @@ export class TUI extends Container {
1955
1989
  }
1956
1990
  }
1957
1991
 
1958
- // Height changes shift the visible window. Repaint when content didn't grow,
1959
- // but skip inside multiplexers (panes manage their own redraws — handled by
1960
- // the multiplexer geometry branch below). Termux is deliberately included:
1961
- // a resize with no content change still exposes or hides viewport rows, and
1962
- // leaving those rows blank lets later appends fill phantom space instead of
1963
- // growing native scrollback.
1964
- if (heightChanged && !contentGrew && !isMultiplexerSession()) {
1965
- return { kind: "viewportRepaint" };
1966
- }
1967
-
1968
- // A height change that also grew the content into a frame that now fits
1969
- // entirely on screen cannot use the diff or append-tail emitters below:
1970
- // both position scrolled rows against the previous viewport top and
1971
- // hardware cursor row, which the reflow just invalidated, so the appended
1972
- // tail lands `height`-delta rows too low. With no overflow there is no
1973
- // native scrollback to preserve, so repaint the viewport at the new
1974
- // geometry. (Height changes with overflow keep the existing deferral.)
1975
- if (heightChanged && newLines.length <= height && !isMultiplexerSession()) {
1976
- return { kind: "viewportRepaint" };
1977
- }
1978
-
1979
- // Any other geometry change (height shrink with content overflowing the
1980
- // viewport, or a width change carrying a pure append) must not reach the
1981
- // anchor-relative diff/append emitters below either. The terminal reflowed
1982
- // its own buffer on resize — a height shrink moves committed rows between
1983
- // scrollback and viewport — so the previous frame's viewport-top and
1984
- // hardware-cursor anchors no longer describe the screen, and scrolling
1985
- // relative to them splices phantom blank rows into native scrollback
1986
- // (stress repro: darwin-normal-large seed 0x5eed1234 op 1062, a
1987
- // resizeHeight coalesced with a streamed append). A resize is an explicit
1988
- // user action, so rebuilding history at the new geometry is the
1989
- // established tradeoff (see the width-change branch above); a reader
1990
- // confirmed scrolled into history is still never yanked. Termux is included
1991
- // (it is not a multiplexer and ED3 clears its own scrollback): a content-
1992
- // bearing resize must not reach the stale-anchor emitters below.
1993
- if ((heightChanged || widthChanged) && !isMultiplexerSession()) {
1994
- // No overflow → nothing of ours in native scrollback to reconcile; an
1995
- // in-place repaint also keeps preexisting shell scrollback intact.
1996
- if (newLines.length <= height) {
1997
- return { kind: "viewportRepaint" };
1998
- }
1999
- const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
2000
- if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
2001
- this.#markNativeScrollbackDirty();
2002
- return { kind: "viewportRepaint" };
2003
- }
2004
- return { kind: "historyRebuild" };
2005
- }
2006
-
2007
- // The same geometry hazard inside a multiplexer: tmux reflows the pane
2008
- // grid (visible rows AND pane history) on resize, so the anchor-relative
2009
- // diff/append emitters below are equally invalid — but a destructive
2010
- // rebuild is impossible there (pane history cannot be cleared; a full
2011
- // replay only appends a duplicate transcript copy). Repaint the visible
2012
- // window in place at the new geometry. Applies even under Termux: a
2013
- // repaint per keyboard-toggle resize is cheaper than splicing phantom
2014
- // rows into the pane.
2015
- if ((heightChanged || widthChanged) && isMultiplexerSession()) {
2016
- return { kind: "viewportRepaint" };
2017
- }
2018
-
2019
1992
  // Configurable shrink-clear: opt-in path that repaints to wipe rows the
2020
1993
  // diff path would leave behind.
2021
1994
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
@@ -2043,6 +2016,14 @@ export class TUI extends Container {
2043
2016
  return { kind: "historyRebuild" };
2044
2017
  }
2045
2018
  this.#markNativeScrollbackDirty();
2019
+ if (
2020
+ nativeViewportAtBottom === undefined &&
2021
+ eagerEraseScrollbackRisk &&
2022
+ !cleanTailAppend &&
2023
+ !this.#eagerNativeScrollbackRebuild
2024
+ ) {
2025
+ return { kind: "deferredMutation" };
2026
+ }
2046
2027
  return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
2047
2028
  }
2048
2029
 
@@ -2149,15 +2130,8 @@ export class TUI extends Container {
2149
2130
  #nativeViewportIsKnownScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
2150
2131
  return nativeViewportAtBottom === false;
2151
2132
  }
2152
-
2153
- #canReplayNativeScrollbackAtCheckpoint(
2154
- nativeViewportAtBottom: boolean | undefined,
2155
- allowUnknownViewport: boolean,
2156
- ): boolean {
2157
- return (
2158
- nativeViewportAtBottom === true ||
2159
- (nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
2160
- );
2133
+ #canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom: boolean | undefined): boolean {
2134
+ return nativeViewportAtBottom === true;
2161
2135
  }
2162
2136
 
2163
2137
  /**
@@ -2167,10 +2141,9 @@ export class TUI extends Container {
2167
2141
  * the native viewport) is safe to emit *during ordinary rendering*. POSIX
2168
2142
  * terminals cannot report whether the user has scrolled up
2169
2143
  * (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
2170
- * treated as unsafe: defer to a non-destructive viewport repaint, mark
2171
- * scrollback dirty, and reconcile history at the next explicit checkpoint
2172
- * ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the
2173
- * editor keystroke has already pinned the terminal to the bottom. Without
2144
+ * treated as unsafe: defer to a non-destructive viewport repaint and keep
2145
+ * scrollback dirty until a later render has a positive at-tail proof. A prompt
2146
+ * submit is no longer treated as proof for unobservable host scrollback.
2174
2147
  * this, every offscreen transcript edit while streaming wiped scrollback and
2175
2148
  * yanked a scrolled-up reader out of their current context.
2176
2149
  * `allowUnknownViewportMutation` (autocomplete/IME) opts directly
@@ -2192,21 +2165,16 @@ export class TUI extends Container {
2192
2165
  newLines: string[],
2193
2166
  height: number,
2194
2167
  liveRegionStart: number | undefined,
2168
+ commitSafeEnd: number | undefined,
2195
2169
  eagerEraseScrollbackRisk: boolean,
2196
2170
  allowUnknownViewportMutation: boolean,
2197
- geometryChanged: boolean,
2198
2171
  ): 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
2172
  if (
2204
2173
  liveRegionStart === undefined ||
2205
2174
  liveRegionStart >= newLines.length ||
2206
2175
  !this.#eagerNativeScrollbackRebuild ||
2207
2176
  !eagerEraseScrollbackRisk ||
2208
2177
  allowUnknownViewportMutation ||
2209
- geometryChanged ||
2210
2178
  isMultiplexerSession()
2211
2179
  ) {
2212
2180
  return undefined;
@@ -2216,25 +2184,28 @@ export class TUI extends Container {
2216
2184
 
2217
2185
  this.#markNativeScrollbackDirty();
2218
2186
  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;
2187
+ // Rows before the live-region boundary are sealed. The commit boundary is
2188
+ // the deeper of the sealed start and the append-only `commitSafeEnd`: a
2189
+ // streaming assistant block reports a `commitSafeEnd` spanning its whole
2190
+ // body, so its head rows that scroll above the viewport commit to native
2191
+ // scrollback instead of vanishing (committed nowhere, repainted nowhere).
2192
+ // A volatile live block (a tool preview that later collapses) omits
2193
+ // `commitSafeEnd`, so the boundary falls back to `liveRegionStart` and its
2194
+ // mutable rows stay deferred — otherwise a pending box that later collapses
2195
+ // to its running/final shape leaves the old top half in scrollback and
2196
+ // repaints the new tail below it, visually splitting one box across the
2197
+ // scrollback seam.
2198
+ const commitBoundary = commitSafeEnd ?? liveRegionStart;
2199
+ const sealedAppendTo = Math.min(naturalViewportTop, commitBoundary);
2200
+ const appendTo = Math.max(0, sealedAppendTo);
2237
2201
  const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
2202
+ // If the live-region collapse would re-expose committed rows already written
2203
+ // to native scrollback, clamp the repaint below that committed prefix so
2204
+ // committed rows are not duplicated. Mutable rows beyond the commit boundary
2205
+ // may remain hidden above the viewport until the next checkpoint rebuild;
2206
+ // that is safer than committing transient rows that can later re-layout.
2207
+ const committedSealedEnd = Math.min(this.#scrollbackHighWater, commitBoundary);
2208
+ const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
2238
2209
  return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
2239
2210
  }
2240
2211
 
@@ -2355,6 +2326,56 @@ export class TUI extends Container {
2355
2326
  this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), toRow);
2356
2327
  }
2357
2328
 
2329
+ /**
2330
+ * Initial foreground-stream paint on ED3-risk hosts with unknown viewport
2331
+ * position. Clears only the visible screen, commits the stable prefix, and
2332
+ * paints the mutable live tail without first writing hidden live rows into
2333
+ * native scrollback.
2334
+ */
2335
+ #emitInitialLiveRegionPinnedPaint(
2336
+ lines: string[],
2337
+ width: number,
2338
+ height: number,
2339
+ cursorPos: { row: number; col: number } | null,
2340
+ liveRegionStart: number,
2341
+ commitSafeEnd: number | undefined,
2342
+ ): void {
2343
+ this.#fullRedrawCount += 1;
2344
+ this.#markNativeScrollbackDirty();
2345
+ const naturalViewportTop = Math.max(0, lines.length - height);
2346
+ const commitBoundary = commitSafeEnd ?? liveRegionStart;
2347
+ const appendTo = Math.max(0, Math.min(naturalViewportTop, commitBoundary, lines.length));
2348
+ const viewportTop = naturalViewportTop;
2349
+
2350
+ let buffer = this.#paintBeginSequence;
2351
+ if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
2352
+ buffer += "\x1b[2J\x1b[H";
2353
+
2354
+ let wroteLine = false;
2355
+ for (let i = 0; i < appendTo; i++) {
2356
+ if (wroteLine) buffer += "\r\n";
2357
+ buffer += this.#fitLineToWidth(lines[i] ?? "", width);
2358
+ wroteLine = true;
2359
+ }
2360
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2361
+ if (wroteLine) buffer += "\r\n";
2362
+ buffer += this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
2363
+ wroteLine = true;
2364
+ }
2365
+
2366
+ const viewportBottomRow = viewportTop + height - 1;
2367
+ const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
2368
+ const parkUp = viewportBottomRow - contentBottomRow;
2369
+ if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
2370
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
2371
+ buffer += seq;
2372
+ buffer += this.#paintEndSequence;
2373
+ this.terminal.write(buffer);
2374
+
2375
+ this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
2376
+ this.#scrollbackHighWater = appendTo;
2377
+ this.#commit(lines, width, height, viewportTop, toRow);
2378
+ }
2358
2379
  /**
2359
2380
  * Rewrite the visible viewport in place. Cursor home, clear each row,
2360
2381
  * emit the bottom-anchored slice of `lines`. No scrollback growth.