@oh-my-pi/pi-tui 15.10.2 → 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,7 +2,21 @@
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
+
11
+ ## [15.10.3] - 2026-06-08
12
+
13
+ ### Fixed
14
+
15
+ - Fixed DEC 2048 in-band resize reports (`CSI 48;rows;cols;hpx;wpx t`) leaking into the focused editor as literal text during a rapid resize. When the window is resized quickly the event loop stays busy long enough for the `StdinBuffer` flush timeout to fire mid-report; the `\x1b[48;…` prefix was emitted as one event and the tail (e.g. `8;125;1156;1125t`) arrived as bare printable characters that the editor inserted. `ProcessTerminal` now reassembles a split in-band report (including a split at the bare `\x1b[4` type field) until its terminator and then drives the resize. A reassembled sequence that turns out not to be a resize report — such as a split kitty key like `\x1b[48;5u` (codepoint 48 = `0`) — is forwarded to the input handler as a single escape sequence rather than dropped or leaked.
16
+ - Coalesced terminal-multiplexer SIGWINCH events into a single forced render once the pane stops resizing so closing/dragging a tmux/screen/zellij split no longer flashes the viewport blank before the new geometry repaints ([#2088](https://github.com/can1357/oh-my-pi/issues/2088)).
17
+
5
18
  ## [15.10.2] - 2026-06-08
19
+
6
20
  ### Added
7
21
 
8
22
  - Added exported `canonicalKeyId` and `addKeyAliases` keybinding helpers so consumers can share the same canonical shortcut matching semantics as `KeybindingsManager`.
@@ -19,6 +33,7 @@
19
33
  - Fixed Windows ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code) parking the viewport at the top of a full paint after a `/resume` or any long-session repaint. `ProcessTerminal#safeWrite` now splits oversized writes into ≤ 8 KiB pieces at line boundaries on `win32` and inside WSL (where stdout still crosses ConPTY at the `wslhost` boundary) so each underlying `WriteFile` stays below the ~32 KiB threshold where ConPTY stops tracking the cursor; the data was always delivered, but the host UI's scroll position would not follow until any focus event forced a re-query. ([#2034](https://github.com/can1357/oh-my-pi/issues/2034))
20
34
 
21
35
  ## [15.10.1] - 2026-06-07
36
+
22
37
  ### Breaking Changes
23
38
 
24
39
  - Removed Kitty temp-file image transmission, its startup support probe, the `PI_KITTY_IMAGE_TRANSMISSION` override, and the temp-file helper exports. Kitty/Ghostty image payloads now stay on in-band base64 before placeholder/direct placement, avoiding blank first renders from temp-file load races.
@@ -85,6 +100,7 @@
85
100
  - Fixed DECCARA background-fill optimization running when synchronized output is disabled, which could expose default-background gaps during rapidly updating tool-use panels ([#2000](https://github.com/can1357/oh-my-pi/issues/2000)).
86
101
 
87
102
  ## [15.9.67] - 2026-06-06
103
+
88
104
  ### Added
89
105
 
90
106
  - Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
@@ -1173,4 +1189,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1173
1189
 
1174
1190
  ### Fixed
1175
1191
 
1176
- - **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))
1192
+ - **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))
@@ -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.2",
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.2",
41
- "@oh-my-pi/pi-utils": "15.10.2",
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
 
@@ -255,6 +318,8 @@ export class ProcessTerminal implements Terminal {
255
318
  #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
256
319
  /** Whether DEC 2048 in-band resize notifications are currently enabled. */
257
320
  #inBandResizeActive = false;
321
+ /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
322
+ #inBandResizeBuffer = "";
258
323
  #reportedColumns?: number;
259
324
  #reportedRows?: number;
260
325
  #osc11PollTimer?: Timer;
@@ -347,7 +412,8 @@ export class ProcessTerminal implements Terminal {
347
412
  // Windows Terminal under WSL has been observed to close the hosting tab
348
413
  // after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
349
414
  // but avoid background polling there.
350
- if (!isWindowsSubsystemForLinux()) {
415
+ const isWSL = process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
416
+ if (!isWSL) {
351
417
  this.#startOsc11Poll();
352
418
  }
353
419
 
@@ -488,6 +554,46 @@ export class ProcessTerminal implements Terminal {
488
554
  }
489
555
  }
490
556
 
557
+ // In-band resize report (DEC 2048) split across stdin reads. The report
558
+ // is `\x1b[48;rows;cols;yPx;xPx t`; when the StdinBuffer flush timeout
559
+ // elapses mid-sequence — common during a rapid resize that keeps the
560
+ // event loop busy — the `\x1b[48;…` prefix arrives as one event and the
561
+ // tail (`…;xPx t`) arrives as bare character events that would otherwise
562
+ // leak into the prompt as literal keystrokes. Reassemble until the
563
+ // terminator, then fall through to the resize handler below. A
564
+ // reassembled sequence that turns out not to be a resize report (e.g. a
565
+ // split kitty `\x1b[48;…u` for a digit key) is forwarded to the input
566
+ // handler rather than dropped.
567
+ const inBandResizePartialPattern = /^\x1b\[4[\d;]*$/;
568
+ const isInBandResizePartial = this.#inBandResizeActive && inBandResizePartialPattern.test(sequence);
569
+ if (this.#inBandResizeBuffer && sequence.startsWith("\x1b")) {
570
+ // A new escape interrupted the partial; the stale partial is
571
+ // unrecoverable. If the new escape is itself an in-band prefix,
572
+ // restart reassembly with it; otherwise let it flow through below.
573
+ this.#inBandResizeBuffer = isInBandResizePartial ? sequence : "";
574
+ if (isInBandResizePartial) return;
575
+ } else if (this.#inBandResizeBuffer || isInBandResizePartial) {
576
+ this.#inBandResizeBuffer += sequence;
577
+ if (this.#inBandResizeBuffer.length > 256) {
578
+ this.#inBandResizeBuffer = "";
579
+ return;
580
+ }
581
+ const lastCode = this.#inBandResizeBuffer.charCodeAt(this.#inBandResizeBuffer.length - 1);
582
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
583
+ // Terminator arrived: let the resize handler below claim it, or
584
+ // fall through to the input handler if it is not a resize report.
585
+ sequence = this.#inBandResizeBuffer;
586
+ this.#inBandResizeBuffer = "";
587
+ } else if (!inBandResizePartialPattern.test(this.#inBandResizeBuffer)) {
588
+ // Diverged from a valid in-band prefix — drop the garbled report.
589
+ this.#inBandResizeBuffer = "";
590
+ return;
591
+ } else {
592
+ // Still accumulating the report.
593
+ return;
594
+ }
595
+ }
596
+
491
597
  // In-band resize report (DEC mode 2048). Unsolicited and not tied to a
492
598
  // sentinel: update reported geometry + cell size, then drive the resize
493
599
  // handler so the renderer reflows.
@@ -970,6 +1076,7 @@ export class ProcessTerminal implements Terminal {
970
1076
  this.#osc99Capabilities.clear();
971
1077
  setOsc99Supported(false);
972
1078
  this.#privateCsiResponseBuffer = "";
1079
+ this.#inBandResizeBuffer = "";
973
1080
  this.#da1SentinelOwners.length = 0;
974
1081
  this.#privateModeCallbacks = [];
975
1082
  this.#privateModeSupport.clear();
@@ -1047,10 +1154,12 @@ export class ProcessTerminal implements Terminal {
1047
1154
  // WSL — `process.platform === "linux"` there, but stdout still
1048
1155
  // crosses into ConPTY at the `wslhost` boundary, so the same per-
1049
1156
  // WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
1050
- // path. See #2034.
1051
- const conptyHosted = process.platform === "win32" || isWindowsSubsystemForLinux();
1052
- if (conptyHosted && data.length > MAX_CONPTY_WRITE_CHUNK) {
1053
- 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)) {
1054
1163
  process.stdout.write(chunk);
1055
1164
  }
1056
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,
@@ -74,11 +74,13 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
74
74
  const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
75
75
  const CURSOR_END = SYNC_OUTPUT_END;
76
76
  const CURSOR_END_NO_SYNC = "";
77
- // Mouse reporting (normal click tracking + SGR extended coordinates), enabled
78
- // only for the lifetime of a fullscreen overlay so the rest of the app keeps the
79
- // terminal's native text selection.
80
- const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1006h";
81
- const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l";
77
+ // Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
78
+ // rest of the app keeps the terminal's native text selection. 1000h = button
79
+ // click tracking, 1003h = any-motion tracking so overlays can light up hover
80
+ // targets (the pointer moving with no button held), 1006h = SGR extended
81
+ // coordinates so columns/rows past 223 are reported.
82
+ const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
83
+ const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
82
84
 
83
85
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
84
86
  type InputListener = (data: string) => InputListenerResult;
@@ -482,6 +484,36 @@ export class TUI extends Container {
482
484
  #renderScheduler: RenderScheduler;
483
485
  #lastRenderAt = 0;
484
486
  static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
487
+ // Pane-reflow settle window for tmux/screen/zellij. The host process gets
488
+ // SIGWINCH (and `process.stdout` already reports the new geometry) before
489
+ // the multiplexer finishes repainting the pane at the new size, and
490
+ // drag-resize/pane-close animations fire several events in flight. A forced
491
+ // render on each SIGWINCH races those mid-reflow paints — the multiplexer's
492
+ // catch-up paint then partially overwrites the TUI output, which the user
493
+ // sees as a viewport flash or blank screen before the next throttled frame
494
+ // arrives (issue #2088). Coalescing every SIGWINCH inside this window into
495
+ // a single forced render lets the multiplexer settle first.
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;
485
517
  #cursorRow = 0; // Logical cursor row (end of rendered content)
486
518
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
487
519
  #hardwareCursorState: HardwareCursorState | null = null;
@@ -549,6 +581,13 @@ export class TUI extends Container {
549
581
  // between the viewport and scrollback, so the previous frame no longer
550
582
  // describes the screen. Tracking only the dimension delta misses this.
551
583
  #resizeEventPending = false;
584
+ // Active multiplexer SIGWINCH debounce. Reset on each event so the timer
585
+ // only fires once the pane stops resizing. Forced renders (resetDisplay,
586
+ // finishSixelProbe, …) issued during the settle window route through the
587
+ // same timer; their `clearScrollback` intent is OR'd into the deferred
588
+ // flag below so the settled paint still honours every caller's request.
589
+ #multiplexerResizeTimer: RenderTimer | undefined;
590
+ #deferredForcedClearScrollback = false;
552
591
  #stopped = false;
553
592
 
554
593
  // Transient alternate-screen state for a fullscreen overlay. While active, the
@@ -858,12 +897,29 @@ export class TUI extends Container {
858
897
  this.terminal.start(
859
898
  data => this.#handleInput(data),
860
899
  () => {
861
- // Repaint immediately rather than via the throttled path: a resize must
862
- // clear and replay at the fresh geometry before the terminal's reflow
863
- // settles into a state a throttled frame would race. Forced render skips
864
- // the 30fps coalescing window, matching resetDisplay()'s prompt repaint.
900
+ // Real terminals deliver SIGWINCH (and the equivalent ConPTY
901
+ // notification) atomically with the new `process.stdout` geometry, so
902
+ // a forced render must fire immediately: it clears and replays at the
903
+ // fresh size before the terminal's reflow settles into a state a
904
+ // throttled frame would race. Multiplexer panes (tmux/screen/zellij)
905
+ // do not give that guarantee. The host receives SIGWINCH while the
906
+ // multiplexer is still mid-reflow — it has not finished repainting
907
+ // the pane buffer at the new size — and a drag-resize or pane-close
908
+ // animation fires several events in flight. Forcing a render on each
909
+ // event races those mid-reflow paints: the multiplexer's catch-up
910
+ // paint then partially overwrites the TUI output, which the user sees
911
+ // as a viewport flash or blank screen before the next throttled
912
+ // frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
913
+ // SIGWINCHes (and any forced repaints arriving during the settle
914
+ // window) into a single render once the pane is quiet —
915
+ // `#resizeEventPending` is set first so the eventual render still
916
+ // classifies as a resize.
865
917
  this.#resizeEventPending = true;
866
- this.requestRender(true);
918
+ if (!isMultiplexerSession()) {
919
+ this.requestRender(true);
920
+ return;
921
+ }
922
+ this.#armMultiplexerResizeTimer(false);
867
923
  },
868
924
  );
869
925
  for (const listener of this.#startListeners) {
@@ -1066,6 +1122,12 @@ export class TUI extends Container {
1066
1122
  this.#renderTimer.cancel();
1067
1123
  this.#renderTimer = undefined;
1068
1124
  }
1125
+ if (this.#multiplexerResizeTimer) {
1126
+ this.#multiplexerResizeTimer.cancel();
1127
+ this.#multiplexerResizeTimer = undefined;
1128
+ }
1129
+ this.#clearPostFullPaintSettle();
1130
+ this.#deferredForcedClearScrollback = false;
1069
1131
  // Place the parent shell on the first line after the rendered content. When
1070
1132
  // that line is still inside the viewport, moving there and writing `\r` is
1071
1133
  // enough; emitting `\r\n` would create an extra blank row. If the content
@@ -1140,6 +1202,15 @@ export class TUI extends Container {
1140
1202
  resetDisplay(): void {
1141
1203
  if (this.#stopped) return;
1142
1204
  this.invalidate();
1205
+ // A reset that lands inside a tmux/screen/zellij resize burst would
1206
+ // paint mid-reflow and re-introduce the flash race (issue #2088).
1207
+ // Fold it into the in-flight debounce instead; the settled paint runs
1208
+ // the same `#prepareForcedRender(!isMultiplexerSession())` path via
1209
+ // `requestRender(true)`, so the clear-scrollback intent is preserved.
1210
+ if (this.#multiplexerResizeTimer) {
1211
+ this.#armMultiplexerResizeTimer(!isMultiplexerSession());
1212
+ return;
1213
+ }
1143
1214
  this.#prepareForcedRender(!isMultiplexerSession());
1144
1215
  this.#resizeEventPending = true;
1145
1216
  this.#renderRequested = false;
@@ -1151,6 +1222,23 @@ export class TUI extends Container {
1151
1222
  const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
1152
1223
  this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
1153
1224
  if (force) {
1225
+ // Forced repaints landing inside the multiplexer resize debounce
1226
+ // (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
1227
+ // `requestRender(true)`) would paint into a still-reflowing pane
1228
+ // and reintroduce the flash race. Fold them into the in-flight
1229
+ // debounce while preserving the caller's `clearScrollback` intent
1230
+ // for the settled paint. The timer's own callback clears
1231
+ // `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
1232
+ // so this guard only catches external callers — the deferred render
1233
+ // itself proceeds straight to `#prepareForcedRender`.
1234
+ if (this.#multiplexerResizeTimer) {
1235
+ this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
1236
+ return;
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();
1154
1242
  this.#prepareForcedRender(options?.clearScrollback === true);
1155
1243
  this.#renderRequested = true;
1156
1244
  this.#renderScheduler.scheduleImmediate(() => {
@@ -1163,11 +1251,121 @@ export class TUI extends Container {
1163
1251
  });
1164
1252
  return;
1165
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
+ }
1166
1275
  if (this.#renderRequested) return;
1167
1276
  this.#renderRequested = true;
1168
1277
  this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
1169
1278
  }
1170
1279
 
1280
+ /**
1281
+ * Arm or extend the multiplexer-resize debounce so a single forced render
1282
+ * fires once the pane is quiet. Called by the SIGWINCH callback on every
1283
+ * resize event, and by `requestRender(true)` / `resetDisplay()` when they
1284
+ * land inside an in-flight settle window. Each call cancels the prior
1285
+ * timer, supersedes any queued throttled render (otherwise it would race
1286
+ * tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
1287
+ * intent into `#deferredForcedClearScrollback` — the timer's callback
1288
+ * consumes that flag exactly once when it re-enters `requestRender(true)`.
1289
+ */
1290
+ #armMultiplexerResizeTimer(clearScrollback: boolean): void {
1291
+ this.#deferredForcedClearScrollback ||= clearScrollback;
1292
+ if (this.#renderTimer) {
1293
+ this.#renderTimer.cancel();
1294
+ this.#renderTimer = undefined;
1295
+ }
1296
+ this.#renderRequested = false;
1297
+ if (this.#multiplexerResizeTimer) {
1298
+ this.#multiplexerResizeTimer.cancel();
1299
+ }
1300
+ this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
1301
+ this.#multiplexerResizeTimer = undefined;
1302
+ if (this.#stopped) {
1303
+ this.#deferredForcedClearScrollback = false;
1304
+ return;
1305
+ }
1306
+ const deferredClearScrollback = this.#deferredForcedClearScrollback;
1307
+ this.#deferredForcedClearScrollback = false;
1308
+ this.requestRender(true, { clearScrollback: deferredClearScrollback });
1309
+ }, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
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
+ }
1171
1369
  #prepareForcedRender(clearScrollback: boolean): void {
1172
1370
  const geometryChanged =
1173
1371
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
@@ -1191,6 +1389,13 @@ export class TUI extends Container {
1191
1389
  if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
1192
1390
  return;
1193
1391
  }
1392
+ // Defer any new throttled render scheduled inside the multiplexer
1393
+ // resize settle window: it would race tmux's mid-reflow pane repaint.
1394
+ // `#renderRequested` stays set so the eventual forced render — armed
1395
+ // by the SIGWINCH callback — picks up the latest component state.
1396
+ if (this.#multiplexerResizeTimer) {
1397
+ return;
1398
+ }
1194
1399
  const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
1195
1400
  const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
1196
1401
  this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
@@ -1826,6 +2031,7 @@ export class TUI extends Container {
1826
2031
  clearViewport: true,
1827
2032
  clearScrollback: !isMultiplexerSession(),
1828
2033
  });
2034
+ if (lines.length > height) this.#armPostFullPaintSettle();
1829
2035
  this.#hasEverRendered = true;
1830
2036
  return;
1831
2037
  case "historyRebuild":
@@ -1834,6 +2040,7 @@ export class TUI extends Container {
1834
2040
  clearViewport: true,
1835
2041
  clearScrollback: !isMultiplexerSession(),
1836
2042
  });
2043
+ if (lines.length > height) this.#armPostFullPaintSettle();
1837
2044
  return;
1838
2045
  case "overlayRebuild":
1839
2046
  this.#clearNativeScrollbackDirty();
@@ -1844,6 +2051,7 @@ export class TUI extends Container {
1844
2051
  clearScrollback: !isMultiplexerSession(),
1845
2052
  });
1846
2053
  this.#emitViewportRepaint(lines, width, height, cursorPos);
2054
+ if (baseLines.length > height) this.#armPostFullPaintSettle();
1847
2055
  return;
1848
2056
  case "liveRegionPinned":
1849
2057
  this.#emitLiveRegionPinnedRepaint(