@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 +11 -1
- package/package.json +3 -3
- package/src/terminal.ts +43 -0
- package/src/tui.ts +111 -10
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
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
|
-
|
|
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(() => {
|