@oh-my-pi/pi-tui 15.10.3 → 15.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.4] - 2026-06-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued *during* the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)).
10
+
5
11
  ## [15.10.3] - 2026-06-08
6
12
 
7
13
  ### Fixed
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Split `data` into chunks no larger than `maxChunkSize`, preferring a line
3
- * boundary (`\n`) as the cut point so escape sequences (which never contain
4
- * `\n`) stay intact. The TUI's full-paint buffers are line-structured
5
- * (`buffer += "\r\n"` between rows), so a newline almost always exists within
6
- * the window. The fallback for a buffer with no newline in range is a hard
7
- * cut at `maxChunkSize`: the ConPTY viewport bug from a single oversized
8
- * write is strictly worse than a one-frame escape-sequence glitch on a buffer
9
- * the renderer effectively never produces.
2
+ * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
3
+ * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
4
+ * escape sequences (which never contain `\n`) stay intact. The TUI's
5
+ * full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
6
+ * so a newline almost always exists within the window. The fallback for a
7
+ * buffer with no newline in range is a hard cut at the last UTF-8 code-point
8
+ * boundary that still fits the ConPTY viewport bug from a single oversized
9
+ * write is strictly worse than a one-frame escape-sequence glitch on a
10
+ * buffer the renderer effectively never produces.
11
+ *
12
+ * UTF-16 code units are walked manually rather than measuring with
13
+ * `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
14
+ * known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
15
+ * bytes across two units, other BMP → 3), and surrogate pairs are kept
16
+ * together so the chunker never splits a non-BMP character.
10
17
  *
11
18
  * Exported for unit testing of the chunking contract; `#safeWrite` is the
12
19
  * sole production caller.
13
20
  */
14
- export declare function chunkForConPTY(data: string, maxChunkSize?: number): string[];
21
+ export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
15
22
  /**
16
23
  * Emergency terminal restore - call this from signal/crash handlers
17
24
  * Resets terminal state without requiring access to the ProcessTerminal instance
@@ -91,6 +98,15 @@ export interface Terminal {
91
98
  */
92
99
  onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
93
100
  }
101
+ /**
102
+ * True when stdout flows through a ConPTY pseudo-console (native win32, or
103
+ * Linux running under WSL where stdout still crosses into ConPTY at the
104
+ * `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
105
+ * quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
106
+ * `#safeWrite` and the renderer's post-big-paint settle gate hang off this
107
+ * single predicate.
108
+ */
109
+ export declare function isConPTYHosted(): boolean;
94
110
  /**
95
111
  * Real terminal using process.stdin/stdout
96
112
  */
@@ -1,5 +1,5 @@
1
1
  import { ImageBudget } from "./components/image";
2
- import type { Terminal } from "./terminal";
2
+ import { type Terminal } from "./terminal";
3
3
  import { visibleWidth } from "./utils";
4
4
  type InputListenerResult = {
5
5
  consume?: boolean;
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.10.3",
4
+ "version": "15.10.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.10.3",
41
- "@oh-my-pi/pi-utils": "15.10.3",
40
+ "@oh-my-pi/pi-natives": "15.10.4",
41
+ "@oh-my-pi/pi-utils": "15.10.4",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
package/src/terminal.ts CHANGED
@@ -10,7 +10,7 @@ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
10
10
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
11
11
 
12
12
  /**
13
- * Maximum bytes per `process.stdout.write` call on Windows.
13
+ * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
14
14
  *
15
15
  * Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
16
16
  * single write exceeds ~32-64 KB, the pseudo-console stops following the
@@ -20,43 +20,96 @@ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
20
20
  * first ~30 lines until any focus event forces the host to re-query the
21
21
  * cursor. The data is delivered correctly — it's purely a viewport-sync bug.
22
22
  *
23
- * 8 KiB is well below the 32 KiB threshold reported on Windows Terminal and
24
- * leaves headroom for the other ConPTY hosts (Tabby, Hyper, VS Code) where
25
- * the exact limit is undocumented. The cost is a handful of extra syscalls
26
- * per full paint invisible compared to the cost of the paint itself.
23
+ * The cap is on **encoded UTF-8 bytes**, not JS code units, because
24
+ * `process.stdout.write(string)` UTF-8-encodes before handing off to
25
+ * `WriteFile`. A pure-CJK transcript row encodes to ~3 bytes per BMP code
26
+ * unit, so a code-unit-based cap of 16 KiB could land at ~48 KiB of actual
27
+ * `WriteFile` traffic and reintroduce the #2034 parked-viewport bug for
28
+ * non-ASCII content.
29
+ *
30
+ * 16 KiB is half the smallest observed Windows Terminal threshold (32 KiB),
31
+ * which keeps the per-write parked-viewport bug fixed by #2034 while halving
32
+ * the WriteFile count on multi-megabyte paints (a 3 MB session resume splits
33
+ * into ~192 chunks instead of ~384). Fewer WriteFiles means fewer chances for
34
+ * WT's viewport-following logic to lose track of the cursor during the burst,
35
+ * which mitigates the residual mid-paint drift the original 8 KiB cap left
36
+ * behind (#2095). Still well clear of the threshold so the other ConPTY hosts
37
+ * (Tabby, Hyper, VS Code) — where the exact limit is undocumented — keep
38
+ * their safety margin.
27
39
  */
28
- const MAX_CONPTY_WRITE_CHUNK = 8 * 1024;
40
+ const MAX_CONPTY_WRITE_CHUNK_BYTES = 16 * 1024;
29
41
 
30
42
  /**
31
- * Split `data` into chunks no larger than `maxChunkSize`, preferring a line
32
- * boundary (`\n`) as the cut point so escape sequences (which never contain
33
- * `\n`) stay intact. The TUI's full-paint buffers are line-structured
34
- * (`buffer += "\r\n"` between rows), so a newline almost always exists within
35
- * the window. The fallback for a buffer with no newline in range is a hard
36
- * cut at `maxChunkSize`: the ConPTY viewport bug from a single oversized
37
- * write is strictly worse than a one-frame escape-sequence glitch on a buffer
38
- * the renderer effectively never produces.
43
+ * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
44
+ * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
45
+ * escape sequences (which never contain `\n`) stay intact. The TUI's
46
+ * full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
47
+ * so a newline almost always exists within the window. The fallback for a
48
+ * buffer with no newline in range is a hard cut at the last UTF-8 code-point
49
+ * boundary that still fits the ConPTY viewport bug from a single oversized
50
+ * write is strictly worse than a one-frame escape-sequence glitch on a
51
+ * buffer the renderer effectively never produces.
52
+ *
53
+ * UTF-16 code units are walked manually rather than measuring with
54
+ * `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
55
+ * known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
56
+ * bytes across two units, other BMP → 3), and surrogate pairs are kept
57
+ * together so the chunker never splits a non-BMP character.
39
58
  *
40
59
  * Exported for unit testing of the chunking contract; `#safeWrite` is the
41
60
  * sole production caller.
42
61
  */
43
- export function chunkForConPTY(data: string, maxChunkSize: number = MAX_CONPTY_WRITE_CHUNK): string[] {
44
- if (data.length <= maxChunkSize) return [data];
62
+ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_WRITE_CHUNK_BYTES): string[] {
63
+ // Fast path: whole buffer fits in one write.
64
+ if (Buffer.byteLength(data, "utf8") <= maxChunkBytes) return [data];
45
65
  const chunks: string[] = [];
66
+ const len = data.length;
46
67
  let pos = 0;
47
- while (pos < data.length) {
48
- const remaining = data.length - pos;
49
- if (remaining <= maxChunkSize) {
68
+ while (pos < len) {
69
+ let bytes = 0;
70
+ // Index just past the most recent `\n` we've consumed inside [pos, i):
71
+ // the natural cut point that leaves escape sequences intact.
72
+ let lastNewlineEnd = -1;
73
+ let i = pos;
74
+ while (i < len) {
75
+ const cu = data.charCodeAt(i);
76
+ let cuLen = 1;
77
+ let cuBytes: number;
78
+ if (cu < 0x80) {
79
+ cuBytes = 1;
80
+ } else if (cu < 0x800) {
81
+ cuBytes = 2;
82
+ } else if (cu >= 0xd800 && cu < 0xdc00) {
83
+ // High surrogate: pair with the following low surrogate (4 bytes
84
+ // across two code units); an unpaired surrogate UTF-8-encodes as
85
+ // the 3-byte U+FFFD replacement character.
86
+ const next = i + 1 < len ? data.charCodeAt(i + 1) : 0;
87
+ if (next >= 0xdc00 && next < 0xe000) {
88
+ cuBytes = 4;
89
+ cuLen = 2;
90
+ } else {
91
+ cuBytes = 3;
92
+ }
93
+ } else {
94
+ // BMP non-surrogate or unpaired low surrogate → 3 bytes.
95
+ cuBytes = 3;
96
+ }
97
+ if (bytes + cuBytes > maxChunkBytes && i > pos) {
98
+ // Would overflow the cap. Cut at the last newline if we found one,
99
+ // otherwise hard-cut at the current code-point boundary.
100
+ const cut = lastNewlineEnd > pos ? lastNewlineEnd : i;
101
+ chunks.push(data.slice(pos, cut));
102
+ pos = cut;
103
+ break;
104
+ }
105
+ bytes += cuBytes;
106
+ i += cuLen;
107
+ if (cu === 0x0a) lastNewlineEnd = i;
108
+ }
109
+ if (i >= len) {
50
110
  chunks.push(data.slice(pos));
51
- break;
111
+ pos = len;
52
112
  }
53
- const windowEnd = pos + maxChunkSize;
54
- // Prefer the last newline inside the window so escape sequences stay
55
- // intact within their chunk; hard-cut at `windowEnd` otherwise.
56
- const nl = data.lastIndexOf("\n", windowEnd - 1);
57
- const cut = nl >= pos ? nl + 1 : windowEnd;
58
- chunks.push(data.slice(pos, cut));
59
- pos = cut;
60
113
  }
61
114
  return chunks;
62
115
  }
@@ -202,7 +255,17 @@ export interface Terminal {
202
255
  onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
203
256
  }
204
257
 
205
- function isWindowsSubsystemForLinux(): boolean {
258
+ /**
259
+ * True when stdout flows through a ConPTY pseudo-console (native win32, or
260
+ * Linux running under WSL where stdout still crosses into ConPTY at the
261
+ * `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
262
+ * quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
263
+ * `#safeWrite` and the renderer's post-big-paint settle gate hang off this
264
+ * single predicate.
265
+ */
266
+ export function isConPTYHosted(): boolean {
267
+ if (process.platform === "win32") return true;
268
+ // WSL: stdout still crosses into ConPTY at the `wslhost` boundary.
206
269
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
207
270
  }
208
271
 
@@ -349,7 +412,8 @@ export class ProcessTerminal implements Terminal {
349
412
  // Windows Terminal under WSL has been observed to close the hosting tab
350
413
  // after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
351
414
  // but avoid background polling there.
352
- if (!isWindowsSubsystemForLinux()) {
415
+ const isWSL = process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
416
+ if (!isWSL) {
353
417
  this.#startOsc11Poll();
354
418
  }
355
419
 
@@ -1090,10 +1154,12 @@ export class ProcessTerminal implements Terminal {
1090
1154
  // WSL — `process.platform === "linux"` there, but stdout still
1091
1155
  // crosses into ConPTY at the `wslhost` boundary, so the same per-
1092
1156
  // WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
1093
- // path. See #2034.
1094
- const conptyHosted = process.platform === "win32" || isWindowsSubsystemForLinux();
1095
- if (conptyHosted && data.length > MAX_CONPTY_WRITE_CHUNK) {
1096
- for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK)) {
1157
+ // path. The cap is on encoded UTF-8 bytes, not JS code units, because
1158
+ // `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
1159
+ // and a code-unit cap would let CJK transcript rows expand past the
1160
+ // threshold. See #2034 and #2095.
1161
+ if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
1162
+ for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
1097
1163
  process.stdout.write(chunk);
1098
1164
  }
1099
1165
  } else {
package/src/tui.ts CHANGED
@@ -17,7 +17,7 @@ import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
17
17
  import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
18
18
  import { planDeccaraFills } from "./deccara";
19
19
  import { isKeyRelease, matchesKey } from "./keys";
20
- import type { Terminal } from "./terminal";
20
+ import { isConPTYHosted, type Terminal } from "./terminal";
21
21
  import {
22
22
  encodeKittyDeleteImage,
23
23
  ImageProtocol,
@@ -494,6 +494,26 @@ export class TUI extends Container {
494
494
  // arrives (issue #2088). Coalescing every SIGWINCH inside this window into
495
495
  // a single forced render lets the multiplexer settle first.
496
496
  static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
497
+ // Post-paint settle window for ConPTY hosts. The `sessionReplace` /
498
+ // `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
499
+ // a transcript that overflows the viewport, scroll-pushing everything past
500
+ // the last `height` rows into native scrollback. Windows Terminal's
501
+ // viewport-follow logic gets lossy during that burst: spinner/blink-driven
502
+ // `requestRender(false)` calls firing inside the window each produce another
503
+ // diff write, and the WT host processes them faster than its viewport
504
+ // tracker can keep up — the visible tail ends up parked a few rows above
505
+ // the actual last row until any focus event (Alt+Tab) forces a host repaint.
506
+ // Coalescing every non-forced render inside this window into a single
507
+ // trailing render lets the host fully settle the big paint before any
508
+ // follow-up writes touch the buffer. The first-ever `initial` paint is
509
+ // deliberately exempt: nothing has been on screen yet, so no drift can
510
+ // have accumulated, and tests that start the TUI over an over-tall
511
+ // component depend on the next paint firing without delay. Only armed on
512
+ // ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
513
+ // drift and would just see an unnecessary post-paint latency. See #2095.
514
+ static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
515
+ #postFullPaintSettleUntilMs = 0;
516
+ #postFullPaintSettleTimer: RenderTimer | undefined;
497
517
  #cursorRow = 0; // Logical cursor row (end of rendered content)
498
518
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
499
519
  #hardwareCursorState: HardwareCursorState | null = null;
@@ -1106,6 +1126,7 @@ export class TUI extends Container {
1106
1126
  this.#multiplexerResizeTimer.cancel();
1107
1127
  this.#multiplexerResizeTimer = undefined;
1108
1128
  }
1129
+ this.#clearPostFullPaintSettle();
1109
1130
  this.#deferredForcedClearScrollback = false;
1110
1131
  // Place the parent shell on the first line after the rendered content. When
1111
1132
  // that line is still inside the viewport, moving there and writing `\r` is
@@ -1214,6 +1235,10 @@ export class TUI extends Container {
1214
1235
  this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
1215
1236
  return;
1216
1237
  }
1238
+ // A forced render preempts the post-full-paint ConPTY settle: it owns
1239
+ // the next paint and is going to redraw the buffer anyway, so the
1240
+ // trailing coalesced render queued by the settle would only race it.
1241
+ this.#clearPostFullPaintSettle();
1217
1242
  this.#prepareForcedRender(options?.clearScrollback === true);
1218
1243
  this.#renderRequested = true;
1219
1244
  this.#renderScheduler.scheduleImmediate(() => {
@@ -1226,6 +1251,27 @@ export class TUI extends Container {
1226
1251
  });
1227
1252
  return;
1228
1253
  }
1254
+ // Coalesce non-forced renders inside the post-full-paint ConPTY settle
1255
+ // window into one trailing render. Spinner/blink/streaming components
1256
+ // otherwise fire `requestRender(false)` at 30 Hz while the host is still
1257
+ // catching up with the previous big paint, and each follow-up viewport
1258
+ // repaint nudges Windows Terminal's viewport tracker further off the
1259
+ // last row (see #2095).
1260
+ if (this.#postFullPaintSettleUntilMs > 0) {
1261
+ const now = this.#renderScheduler.now();
1262
+ if (now < this.#postFullPaintSettleUntilMs) {
1263
+ if (this.#postFullPaintSettleTimer === undefined) {
1264
+ this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
1265
+ this.#postFullPaintSettleTimer = undefined;
1266
+ this.#postFullPaintSettleUntilMs = 0;
1267
+ if (this.#stopped) return;
1268
+ this.requestRender(false);
1269
+ }, this.#postFullPaintSettleUntilMs - now);
1270
+ }
1271
+ return;
1272
+ }
1273
+ this.#postFullPaintSettleUntilMs = 0;
1274
+ }
1229
1275
  if (this.#renderRequested) return;
1230
1276
  this.#renderRequested = true;
1231
1277
  this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
@@ -1262,6 +1308,64 @@ export class TUI extends Container {
1262
1308
  this.requestRender(true, { clearScrollback: deferredClearScrollback });
1263
1309
  }, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
1264
1310
  }
1311
+
1312
+ /**
1313
+ * Arm the post-full-paint settle window after an `#emitFullPaint` that
1314
+ * pushed content into native scrollback on a ConPTY host. Idempotent inside
1315
+ * the window: a later overflowing paint extends `until` to the later
1316
+ * deadline so back-to-back big paints do not double-fire the trailing
1317
+ * coalesced render, and the existing deferred timer is rescheduled to the
1318
+ * later deadline.
1319
+ *
1320
+ * Mid-composition callers (most notably `ImageBudget.endPass()`, which can
1321
+ * call `requestRender()` from inside the in-flight paint when a new image
1322
+ * trips the budget) queue their render *before* the settle exists, so they
1323
+ * fall through the gate and set `#renderRequested` / `#renderTimer` on the
1324
+ * 30 Hz throttle. Without absorbing those, the throttled follow-up fires
1325
+ * inside the 150 ms quiet window and reintroduces the cascade the settle
1326
+ * was meant to stop. Cancel both, then eagerly arm the trailing settle
1327
+ * timer so the in-flight request still rides one coalesced render at the
1328
+ * end of the window. See #2095.
1329
+ */
1330
+ #armPostFullPaintSettle(): void {
1331
+ if (!isConPTYHosted()) return;
1332
+ const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
1333
+ if (until <= this.#postFullPaintSettleUntilMs) return;
1334
+ this.#postFullPaintSettleUntilMs = until;
1335
+ const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
1336
+ // Reclaim any render that was queued during the in-flight composition:
1337
+ // `#renderRequested` was set before the settle existed and would
1338
+ // otherwise fire on the standard throttle inside the window.
1339
+ this.#renderRequested = false;
1340
+ if (this.#renderTimer) {
1341
+ this.#renderTimer.cancel();
1342
+ this.#renderTimer = undefined;
1343
+ }
1344
+ if (this.#postFullPaintSettleTimer) {
1345
+ this.#postFullPaintSettleTimer.cancel();
1346
+ this.#postFullPaintSettleTimer = undefined;
1347
+ }
1348
+ if (hadPendingRender) {
1349
+ // Replay the absorbed request via the trailing settle timer so the
1350
+ // caller's render still happens — just deferred to the end of the
1351
+ // window. Subsequent `requestRender(false)` calls during the
1352
+ // settle see this timer and fold into it (existing gate at L1263).
1353
+ this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
1354
+ this.#postFullPaintSettleTimer = undefined;
1355
+ this.#postFullPaintSettleUntilMs = 0;
1356
+ if (this.#stopped) return;
1357
+ this.requestRender(false);
1358
+ }, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
1359
+ }
1360
+ }
1361
+
1362
+ #clearPostFullPaintSettle(): void {
1363
+ if (this.#postFullPaintSettleTimer) {
1364
+ this.#postFullPaintSettleTimer.cancel();
1365
+ this.#postFullPaintSettleTimer = undefined;
1366
+ }
1367
+ this.#postFullPaintSettleUntilMs = 0;
1368
+ }
1265
1369
  #prepareForcedRender(clearScrollback: boolean): void {
1266
1370
  const geometryChanged =
1267
1371
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
@@ -1927,6 +2031,7 @@ export class TUI extends Container {
1927
2031
  clearViewport: true,
1928
2032
  clearScrollback: !isMultiplexerSession(),
1929
2033
  });
2034
+ if (lines.length > height) this.#armPostFullPaintSettle();
1930
2035
  this.#hasEverRendered = true;
1931
2036
  return;
1932
2037
  case "historyRebuild":
@@ -1935,6 +2040,7 @@ export class TUI extends Container {
1935
2040
  clearViewport: true,
1936
2041
  clearScrollback: !isMultiplexerSession(),
1937
2042
  });
2043
+ if (lines.length > height) this.#armPostFullPaintSettle();
1938
2044
  return;
1939
2045
  case "overlayRebuild":
1940
2046
  this.#clearNativeScrollbackDirty();
@@ -1945,6 +2051,7 @@ export class TUI extends Container {
1945
2051
  clearScrollback: !isMultiplexerSession(),
1946
2052
  });
1947
2053
  this.#emitViewportRepaint(lines, width, height, cursorPos);
2054
+ if (baseLines.length > height) this.#armPostFullPaintSettle();
1948
2055
  return;
1949
2056
  case "liveRegionPinned":
1950
2057
  this.#emitLiveRegionPinnedRepaint(