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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.3] - 2026-06-08
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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)).
11
+
5
12
  ## [15.10.2] - 2026-06-08
13
+
6
14
  ### Added
7
15
 
8
16
  - Added exported `canonicalKeyId` and `addKeyAliases` keybinding helpers so consumers can share the same canonical shortcut matching semantics as `KeybindingsManager`.
@@ -19,6 +27,7 @@
19
27
  - 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
28
 
21
29
  ## [15.10.1] - 2026-06-07
30
+
22
31
  ### Breaking Changes
23
32
 
24
33
  - 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 +94,7 @@
85
94
  - 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
95
 
87
96
  ## [15.9.67] - 2026-06-06
97
+
88
98
  ### Added
89
99
 
90
100
  - Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
@@ -1173,4 +1183,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1173
1183
 
1174
1184
  ### Fixed
1175
1185
 
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))
1186
+ - **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))
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.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.10.2",
41
- "@oh-my-pi/pi-utils": "15.10.2",
40
+ "@oh-my-pi/pi-natives": "15.10.3",
41
+ "@oh-my-pi/pi-utils": "15.10.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
package/src/terminal.ts CHANGED
@@ -255,6 +255,8 @@ export class ProcessTerminal implements Terminal {
255
255
  #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
256
256
  /** Whether DEC 2048 in-band resize notifications are currently enabled. */
257
257
  #inBandResizeActive = false;
258
+ /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
259
+ #inBandResizeBuffer = "";
258
260
  #reportedColumns?: number;
259
261
  #reportedRows?: number;
260
262
  #osc11PollTimer?: Timer;
@@ -488,6 +490,46 @@ export class ProcessTerminal implements Terminal {
488
490
  }
489
491
  }
490
492
 
493
+ // In-band resize report (DEC 2048) split across stdin reads. The report
494
+ // is `\x1b[48;rows;cols;yPx;xPx t`; when the StdinBuffer flush timeout
495
+ // elapses mid-sequence — common during a rapid resize that keeps the
496
+ // event loop busy — the `\x1b[48;…` prefix arrives as one event and the
497
+ // tail (`…;xPx t`) arrives as bare character events that would otherwise
498
+ // leak into the prompt as literal keystrokes. Reassemble until the
499
+ // terminator, then fall through to the resize handler below. A
500
+ // reassembled sequence that turns out not to be a resize report (e.g. a
501
+ // split kitty `\x1b[48;…u` for a digit key) is forwarded to the input
502
+ // handler rather than dropped.
503
+ const inBandResizePartialPattern = /^\x1b\[4[\d;]*$/;
504
+ const isInBandResizePartial = this.#inBandResizeActive && inBandResizePartialPattern.test(sequence);
505
+ if (this.#inBandResizeBuffer && sequence.startsWith("\x1b")) {
506
+ // A new escape interrupted the partial; the stale partial is
507
+ // unrecoverable. If the new escape is itself an in-band prefix,
508
+ // restart reassembly with it; otherwise let it flow through below.
509
+ this.#inBandResizeBuffer = isInBandResizePartial ? sequence : "";
510
+ if (isInBandResizePartial) return;
511
+ } else if (this.#inBandResizeBuffer || isInBandResizePartial) {
512
+ this.#inBandResizeBuffer += sequence;
513
+ if (this.#inBandResizeBuffer.length > 256) {
514
+ this.#inBandResizeBuffer = "";
515
+ return;
516
+ }
517
+ const lastCode = this.#inBandResizeBuffer.charCodeAt(this.#inBandResizeBuffer.length - 1);
518
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
519
+ // Terminator arrived: let the resize handler below claim it, or
520
+ // fall through to the input handler if it is not a resize report.
521
+ sequence = this.#inBandResizeBuffer;
522
+ this.#inBandResizeBuffer = "";
523
+ } else if (!inBandResizePartialPattern.test(this.#inBandResizeBuffer)) {
524
+ // Diverged from a valid in-band prefix — drop the garbled report.
525
+ this.#inBandResizeBuffer = "";
526
+ return;
527
+ } else {
528
+ // Still accumulating the report.
529
+ return;
530
+ }
531
+ }
532
+
491
533
  // In-band resize report (DEC mode 2048). Unsolicited and not tied to a
492
534
  // sentinel: update reported geometry + cell size, then drive the resize
493
535
  // handler so the renderer reflows.
@@ -970,6 +1012,7 @@ export class ProcessTerminal implements Terminal {
970
1012
  this.#osc99Capabilities.clear();
971
1013
  setOsc99Supported(false);
972
1014
  this.#privateCsiResponseBuffer = "";
1015
+ this.#inBandResizeBuffer = "";
973
1016
  this.#da1SentinelOwners.length = 0;
974
1017
  this.#privateModeCallbacks = [];
975
1018
  this.#privateModeSupport.clear();
package/src/tui.ts CHANGED
@@ -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,16 @@ 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;
485
497
  #cursorRow = 0; // Logical cursor row (end of rendered content)
486
498
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
487
499
  #hardwareCursorState: HardwareCursorState | null = null;
@@ -549,6 +561,13 @@ export class TUI extends Container {
549
561
  // between the viewport and scrollback, so the previous frame no longer
550
562
  // describes the screen. Tracking only the dimension delta misses this.
551
563
  #resizeEventPending = false;
564
+ // Active multiplexer SIGWINCH debounce. Reset on each event so the timer
565
+ // only fires once the pane stops resizing. Forced renders (resetDisplay,
566
+ // finishSixelProbe, …) issued during the settle window route through the
567
+ // same timer; their `clearScrollback` intent is OR'd into the deferred
568
+ // flag below so the settled paint still honours every caller's request.
569
+ #multiplexerResizeTimer: RenderTimer | undefined;
570
+ #deferredForcedClearScrollback = false;
552
571
  #stopped = false;
553
572
 
554
573
  // Transient alternate-screen state for a fullscreen overlay. While active, the
@@ -858,12 +877,29 @@ export class TUI extends Container {
858
877
  this.terminal.start(
859
878
  data => this.#handleInput(data),
860
879
  () => {
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.
880
+ // Real terminals deliver SIGWINCH (and the equivalent ConPTY
881
+ // notification) atomically with the new `process.stdout` geometry, so
882
+ // a forced render must fire immediately: it clears and replays at the
883
+ // fresh size before the terminal's reflow settles into a state a
884
+ // throttled frame would race. Multiplexer panes (tmux/screen/zellij)
885
+ // do not give that guarantee. The host receives SIGWINCH while the
886
+ // multiplexer is still mid-reflow — it has not finished repainting
887
+ // the pane buffer at the new size — and a drag-resize or pane-close
888
+ // animation fires several events in flight. Forcing a render on each
889
+ // event races those mid-reflow paints: the multiplexer's catch-up
890
+ // paint then partially overwrites the TUI output, which the user sees
891
+ // as a viewport flash or blank screen before the next throttled
892
+ // frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
893
+ // SIGWINCHes (and any forced repaints arriving during the settle
894
+ // window) into a single render once the pane is quiet —
895
+ // `#resizeEventPending` is set first so the eventual render still
896
+ // classifies as a resize.
865
897
  this.#resizeEventPending = true;
866
- this.requestRender(true);
898
+ if (!isMultiplexerSession()) {
899
+ this.requestRender(true);
900
+ return;
901
+ }
902
+ this.#armMultiplexerResizeTimer(false);
867
903
  },
868
904
  );
869
905
  for (const listener of this.#startListeners) {
@@ -1066,6 +1102,11 @@ export class TUI extends Container {
1066
1102
  this.#renderTimer.cancel();
1067
1103
  this.#renderTimer = undefined;
1068
1104
  }
1105
+ if (this.#multiplexerResizeTimer) {
1106
+ this.#multiplexerResizeTimer.cancel();
1107
+ this.#multiplexerResizeTimer = undefined;
1108
+ }
1109
+ this.#deferredForcedClearScrollback = false;
1069
1110
  // Place the parent shell on the first line after the rendered content. When
1070
1111
  // that line is still inside the viewport, moving there and writing `\r` is
1071
1112
  // enough; emitting `\r\n` would create an extra blank row. If the content
@@ -1140,6 +1181,15 @@ export class TUI extends Container {
1140
1181
  resetDisplay(): void {
1141
1182
  if (this.#stopped) return;
1142
1183
  this.invalidate();
1184
+ // A reset that lands inside a tmux/screen/zellij resize burst would
1185
+ // paint mid-reflow and re-introduce the flash race (issue #2088).
1186
+ // Fold it into the in-flight debounce instead; the settled paint runs
1187
+ // the same `#prepareForcedRender(!isMultiplexerSession())` path via
1188
+ // `requestRender(true)`, so the clear-scrollback intent is preserved.
1189
+ if (this.#multiplexerResizeTimer) {
1190
+ this.#armMultiplexerResizeTimer(!isMultiplexerSession());
1191
+ return;
1192
+ }
1143
1193
  this.#prepareForcedRender(!isMultiplexerSession());
1144
1194
  this.#resizeEventPending = true;
1145
1195
  this.#renderRequested = false;
@@ -1151,6 +1201,19 @@ export class TUI extends Container {
1151
1201
  const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
1152
1202
  this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
1153
1203
  if (force) {
1204
+ // Forced repaints landing inside the multiplexer resize debounce
1205
+ // (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
1206
+ // `requestRender(true)`) would paint into a still-reflowing pane
1207
+ // and reintroduce the flash race. Fold them into the in-flight
1208
+ // debounce while preserving the caller's `clearScrollback` intent
1209
+ // for the settled paint. The timer's own callback clears
1210
+ // `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
1211
+ // so this guard only catches external callers — the deferred render
1212
+ // itself proceeds straight to `#prepareForcedRender`.
1213
+ if (this.#multiplexerResizeTimer) {
1214
+ this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
1215
+ return;
1216
+ }
1154
1217
  this.#prepareForcedRender(options?.clearScrollback === true);
1155
1218
  this.#renderRequested = true;
1156
1219
  this.#renderScheduler.scheduleImmediate(() => {
@@ -1168,6 +1231,37 @@ export class TUI extends Container {
1168
1231
  this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
1169
1232
  }
1170
1233
 
1234
+ /**
1235
+ * Arm or extend the multiplexer-resize debounce so a single forced render
1236
+ * fires once the pane is quiet. Called by the SIGWINCH callback on every
1237
+ * resize event, and by `requestRender(true)` / `resetDisplay()` when they
1238
+ * land inside an in-flight settle window. Each call cancels the prior
1239
+ * timer, supersedes any queued throttled render (otherwise it would race
1240
+ * tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
1241
+ * intent into `#deferredForcedClearScrollback` — the timer's callback
1242
+ * consumes that flag exactly once when it re-enters `requestRender(true)`.
1243
+ */
1244
+ #armMultiplexerResizeTimer(clearScrollback: boolean): void {
1245
+ this.#deferredForcedClearScrollback ||= clearScrollback;
1246
+ if (this.#renderTimer) {
1247
+ this.#renderTimer.cancel();
1248
+ this.#renderTimer = undefined;
1249
+ }
1250
+ this.#renderRequested = false;
1251
+ if (this.#multiplexerResizeTimer) {
1252
+ this.#multiplexerResizeTimer.cancel();
1253
+ }
1254
+ this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
1255
+ this.#multiplexerResizeTimer = undefined;
1256
+ if (this.#stopped) {
1257
+ this.#deferredForcedClearScrollback = false;
1258
+ return;
1259
+ }
1260
+ const deferredClearScrollback = this.#deferredForcedClearScrollback;
1261
+ this.#deferredForcedClearScrollback = false;
1262
+ this.requestRender(true, { clearScrollback: deferredClearScrollback });
1263
+ }, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
1264
+ }
1171
1265
  #prepareForcedRender(clearScrollback: boolean): void {
1172
1266
  const geometryChanged =
1173
1267
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
@@ -1191,6 +1285,13 @@ export class TUI extends Container {
1191
1285
  if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
1192
1286
  return;
1193
1287
  }
1288
+ // Defer any new throttled render scheduled inside the multiplexer
1289
+ // resize settle window: it would race tmux's mid-reflow pane repaint.
1290
+ // `#renderRequested` stays set so the eventual forced render — armed
1291
+ // by the SIGWINCH callback — picks up the latest component state.
1292
+ if (this.#multiplexerResizeTimer) {
1293
+ return;
1294
+ }
1194
1295
  const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
1195
1296
  const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
1196
1297
  this.#renderTimer = this.#renderScheduler.scheduleRender(() => {