@oh-my-pi/pi-tui 16.0.1 → 16.0.2
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 +9 -0
- package/package.json +3 -3
- package/src/keys.ts +31 -7
- package/src/terminal.ts +45 -8
- package/src/tui.ts +46 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.2] - 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed VS Code integrated terminal keypad digit CSI-u input being handled as navigation instead of text.
|
|
10
|
+
- Fixed xterm-compatible terminals scrolling the native viewport to the bottom on prompt-editor keypresses by disabling `?1010`/`?1011` while the TUI owns the TTY and restoring the prior set modes on exit ([#2732](https://github.com/can1357/oh-my-pi/issues/2732)).
|
|
11
|
+
- Fixed CMUX sessions being treated as direct terminals during resize/reset because they do not set `TMUX`/`STY`/`ZELLIJ` and may run with `TERM=dumb`; the renderer now treats CMUX workspace/surface env markers as multiplexer signals and preserves pane scrollback instead of emitting ED3 (`CSI 3 J`).
|
|
12
|
+
- Fixed a self-sustaining resize-redraw storm in Warp: the non-multiplexer resize fast path borrows the alternate screen, and Warp re-reports a one-row-different size whenever the alt buffer is toggled, so each drag frame fed back a fresh resize event and the TUI flooded ED3 full repaints with stable geometry. Resize now repaints in place (no alt-screen borrow, no ED3 rewrap) on terminals that re-report size on alt-screen toggles, matching the multiplexer path. Overridable with `PI_TUI_RESIZE_IN_PLACE=1|0`.
|
|
13
|
+
|
|
5
14
|
## [16.0.1] - 2026-06-15
|
|
6
15
|
|
|
7
16
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.2",
|
|
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": "16.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.0.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.0.2",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/keys.ts
CHANGED
|
@@ -303,7 +303,7 @@ const KITTY_MOD_ALT = 2;
|
|
|
303
303
|
const KITTY_MOD_CTRL = 4;
|
|
304
304
|
const KITTY_MOD_SUPER = 8;
|
|
305
305
|
const KITTY_MOD_NUM_LOCK = 128;
|
|
306
|
-
const KITTY_LOCK_MASK = 64 +
|
|
306
|
+
const KITTY_LOCK_MASK = 64 + KITTY_MOD_NUM_LOCK; // Caps Lock + Num Lock
|
|
307
307
|
const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
308
308
|
const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
|
|
309
309
|
57410: "/",
|
|
@@ -409,7 +409,7 @@ function decodeKittyPrintable(data: string): string | undefined {
|
|
|
409
409
|
.split(":")
|
|
410
410
|
.filter(Boolean)
|
|
411
411
|
.map(value => Number.parseInt(value, 10))
|
|
412
|
-
.filter(value => Number.isFinite(value) && value >= 32);
|
|
412
|
+
.filter(value => Number.isFinite(value) && value >= 32 && value !== 127);
|
|
413
413
|
if (codepoints.length > 0) {
|
|
414
414
|
try {
|
|
415
415
|
return String.fromCodePoint(...codepoints);
|
|
@@ -421,7 +421,7 @@ function decodeKittyPrintable(data: string): string | undefined {
|
|
|
421
421
|
const keypadOperatorText = KITTY_KEYPAD_OPERATOR_TEXT[codepoint];
|
|
422
422
|
if (keypadOperatorText) return keypadOperatorText;
|
|
423
423
|
|
|
424
|
-
if (effectiveMod === 0
|
|
424
|
+
if (effectiveMod === 0) {
|
|
425
425
|
const numpadText = KITTY_NUMPAD_TEXT[codepoint];
|
|
426
426
|
if (numpadText) return numpadText;
|
|
427
427
|
}
|
|
@@ -435,7 +435,7 @@ function decodeKittyPrintable(data: string): string | undefined {
|
|
|
435
435
|
return undefined;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
-
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
|
|
438
|
+
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32 || effectiveCodepoint === 127) return undefined;
|
|
439
439
|
|
|
440
440
|
try {
|
|
441
441
|
return String.fromCodePoint(effectiveCodepoint);
|
|
@@ -486,7 +486,7 @@ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
|
|
|
486
486
|
if (!parsed) return undefined;
|
|
487
487
|
const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
|
|
488
488
|
if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
|
|
489
|
-
if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined;
|
|
489
|
+
if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32 || parsed.codepoint === 127) return undefined;
|
|
490
490
|
try {
|
|
491
491
|
return String.fromCodePoint(parsed.codepoint);
|
|
492
492
|
} catch {
|
|
@@ -504,6 +504,30 @@ export function decodePrintableKey(data: string): string | undefined {
|
|
|
504
504
|
return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Decode a Kitty CSI-u keypad sequence (numpad digits / keypad operators) into the
|
|
509
|
+
* text it produces, or `undefined` for any non-keypad sequence.
|
|
510
|
+
*
|
|
511
|
+
* The native key matcher classifies bare numpad codepoints (those without a NumLock
|
|
512
|
+
* modifier bit) as navigation keys, but terminals such as the VS Code integrated
|
|
513
|
+
* terminal emit those codepoints for real digit input. Restricting the fast path to
|
|
514
|
+
* keypad codepoints keeps canonical named keys (space, backspace, shifted keys, and
|
|
515
|
+
* modifyOtherKeys sequences) flowing through native normalization.
|
|
516
|
+
*/
|
|
517
|
+
function decodeKittyKeypadText(data: string): string | undefined {
|
|
518
|
+
const match = data.match(KITTY_CSI_U_PATTERN);
|
|
519
|
+
if (!match) return undefined;
|
|
520
|
+
const codepoint = Number.parseInt(match[1] ?? "", 10);
|
|
521
|
+
if (!(codepoint in KITTY_NUMPAD_TEXT) && !(codepoint in KITTY_KEYPAD_OPERATOR_TEXT)) return undefined;
|
|
522
|
+
return decodeKittyPrintable(data);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined {
|
|
526
|
+
const printable = decodeKittyKeypadText(data);
|
|
527
|
+
if (printable === undefined) return undefined;
|
|
528
|
+
return printable === keyId;
|
|
529
|
+
}
|
|
530
|
+
|
|
507
531
|
/**
|
|
508
532
|
* Match input data against a key identifier string.
|
|
509
533
|
*
|
|
@@ -521,7 +545,7 @@ export function decodePrintableKey(data: string): string | undefined {
|
|
|
521
545
|
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
|
522
546
|
*/
|
|
523
547
|
export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
524
|
-
return matchesKeyNative(data, keyId, kittyProtocolActive);
|
|
548
|
+
return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive);
|
|
525
549
|
}
|
|
526
550
|
|
|
527
551
|
/**
|
|
@@ -533,5 +557,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
|
533
557
|
* @param data - Raw input data from terminal
|
|
534
558
|
*/
|
|
535
559
|
export function parseKey(data: string): string | undefined {
|
|
536
|
-
return parseKeyNative(data, kittyProtocolActive) ?? undefined;
|
|
560
|
+
return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
|
|
537
561
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -375,6 +375,20 @@ function parseOsc99KeyValues(section: string): Map<string, string> {
|
|
|
375
375
|
}
|
|
376
376
|
return values;
|
|
377
377
|
}
|
|
378
|
+
const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
|
|
379
|
+
|
|
380
|
+
function isXtermScrollToBottomMode(mode: number): boolean {
|
|
381
|
+
return mode === 1010 || mode === 1011;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isPrivateModeSet(status: string): boolean {
|
|
385
|
+
return status === "1" || status === "3";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isPrivateModeSupported(status: string): boolean {
|
|
389
|
+
return status !== "0" && status !== "4";
|
|
390
|
+
}
|
|
391
|
+
|
|
378
392
|
/**
|
|
379
393
|
* Real terminal using process.stdin/stdout
|
|
380
394
|
*/
|
|
@@ -397,6 +411,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
397
411
|
};
|
|
398
412
|
|
|
399
413
|
#windowsVTInputRestore?: () => void;
|
|
414
|
+
#xtermScrollToBottomRestoreModes = new Set<number>();
|
|
400
415
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
401
416
|
#appearance: TerminalAppearance | undefined;
|
|
402
417
|
#osc11Pending = false;
|
|
@@ -518,13 +533,17 @@ export class ProcessTerminal implements Terminal {
|
|
|
518
533
|
// Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
|
|
519
534
|
// gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
|
|
520
535
|
// only after the terminal confirms support; 2031 (appearance change
|
|
521
|
-
// notifications) stops the OSC 11 poll once confirmed
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
536
|
+
// notifications) stops the OSC 11 poll once confirmed. Xterm ?1010/?1011
|
|
537
|
+
// are disabled while OMP owns the TTY so typing in the editor does not
|
|
538
|
+
// force a reader scrolled into native history back to the tail. Each probe
|
|
539
|
+
// rides the shared DA1 sentinel, so terminals that ignore DECRQM resolve as
|
|
540
|
+
// unsupported when the DA1 reply arrives.
|
|
525
541
|
this.#queryPrivateMode(2026);
|
|
526
542
|
this.#queryPrivateMode(2048);
|
|
527
543
|
this.#queryPrivateMode(2031);
|
|
544
|
+
for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
|
|
545
|
+
this.#queryPrivateMode(mode);
|
|
546
|
+
}
|
|
528
547
|
}
|
|
529
548
|
|
|
530
549
|
/**
|
|
@@ -709,11 +728,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
709
728
|
// DECRPM private-mode report. Resolves the matching probe by mode; the
|
|
710
729
|
// owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
|
|
711
730
|
// once resolved). Per DECRPM, status 0 = unrecognized, 1/2 =
|
|
712
|
-
// set/reset, 3 = permanently set, and 4 = permanently reset.
|
|
713
|
-
// settable or permanently-set modes are useful for features we enable.
|
|
731
|
+
// set/reset, 3 = permanently set, and 4 = permanently reset.
|
|
714
732
|
const decrpmMatch = sequence.match(decrpmResponsePattern);
|
|
715
733
|
if (decrpmMatch) {
|
|
716
|
-
this.#
|
|
734
|
+
this.#handlePrivateModeReport(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2]!);
|
|
717
735
|
return;
|
|
718
736
|
}
|
|
719
737
|
|
|
@@ -1021,6 +1039,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
1021
1039
|
this.#safeWrite(`\x1b[?${mode}$p\x1b[c`);
|
|
1022
1040
|
}
|
|
1023
1041
|
|
|
1042
|
+
#handlePrivateModeReport(mode: number, status: string): void {
|
|
1043
|
+
this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
|
|
1044
|
+
if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
|
|
1045
|
+
this.#disableXtermScrollToBottomMode(mode);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1024
1049
|
/**
|
|
1025
1050
|
* Record DECRQM support for a private mode (idempotent — first result wins)
|
|
1026
1051
|
* and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
|
|
@@ -1042,6 +1067,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
1042
1067
|
if (mode === 2031 && supported) this.#stopOsc11Poll();
|
|
1043
1068
|
}
|
|
1044
1069
|
|
|
1070
|
+
#disableXtermScrollToBottomMode(mode: number): void {
|
|
1071
|
+
if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
|
|
1072
|
+
this.#xtermScrollToBottomRestoreModes.add(mode);
|
|
1073
|
+
this.#safeWrite(`\x1b[?${mode}l`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1045
1076
|
/**
|
|
1046
1077
|
* Enable DEC 2048 in-band resize notifications. The terminal emits an initial
|
|
1047
1078
|
* report immediately, seeding reported geometry and cell dimensions.
|
|
@@ -1170,7 +1201,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
1170
1201
|
// Disable Mode 2031 appearance change notifications
|
|
1171
1202
|
this.#safeWrite("\x1b[?2031l");
|
|
1172
1203
|
|
|
1173
|
-
//
|
|
1204
|
+
// Restore xterm scroll-to-bottom modes that were set before startup.
|
|
1205
|
+
for (const mode of this.#xtermScrollToBottomRestoreModes) {
|
|
1206
|
+
this.#safeWrite(`\x1b[?${mode}h`);
|
|
1207
|
+
}
|
|
1208
|
+
this.#xtermScrollToBottomRestoreModes.clear();
|
|
1209
|
+
|
|
1174
1210
|
if (this.#inBandResizeActive) {
|
|
1175
1211
|
this.#safeWrite("\x1b[?2048l");
|
|
1176
1212
|
this.#inBandResizeActive = false;
|
|
@@ -1193,6 +1229,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1193
1229
|
this.#da1SentinelOwners.length = 0;
|
|
1194
1230
|
this.#privateModeCallbacks = [];
|
|
1195
1231
|
this.#privateModeSupport.clear();
|
|
1232
|
+
this.#xtermScrollToBottomRestoreModes.clear();
|
|
1196
1233
|
this.#reportedColumns = undefined;
|
|
1197
1234
|
this.#reportedRows = undefined;
|
|
1198
1235
|
|
package/src/tui.ts
CHANGED
|
@@ -370,15 +370,48 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
370
370
|
|
|
371
371
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
372
372
|
function isMultiplexerSession(): boolean {
|
|
373
|
-
// TMUX/STY/ZELLIJ are
|
|
374
|
-
// TERM
|
|
375
|
-
//
|
|
376
|
-
//
|
|
373
|
+
// TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
|
|
374
|
+
// signals. TERM can also survive when those are stripped (`sudo` without -E,
|
|
375
|
+
// `su`, env-sanitizing launchers/ssh), so keep the TERM prefix fallback aligned
|
|
376
|
+
// with sibling multiplexer checks (terminal-capabilities.ts). Misclassifying a
|
|
377
|
+
// multiplexer as a direct terminal lets resize/reset paths emit ED3 (`CSI 3 J`)
|
|
378
|
+
// and wipe pane scrollback. Do not use CMUX_SOCKET_PATH here: it is a CLI socket
|
|
379
|
+
// override and can be set outside a CMUX terminal.
|
|
377
380
|
if (Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ) return true;
|
|
381
|
+
if (Bun.env.CMUX_WORKSPACE_ID || Bun.env.CMUX_SURFACE_ID) return true;
|
|
378
382
|
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
379
383
|
return term.startsWith("tmux") || term.startsWith("screen");
|
|
380
384
|
}
|
|
381
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Terminals that re-report their size whenever the alternate screen buffer is
|
|
388
|
+
* toggled. The non-multiplexer resize fast path ({@link TUI.#beginResizeViewport})
|
|
389
|
+
* borrows the alternate screen for throwaway drag frames; on these terminals
|
|
390
|
+
* entering/leaving the alt buffer emits a fresh SIGWINCH (Warp reports a height
|
|
391
|
+
* one row different for the alt buffer), which re-enters the fast path — a
|
|
392
|
+
* self-sustaining resize loop that floods ED3 full repaints even though the
|
|
393
|
+
* geometry never actually changes. Routing them through the in-place
|
|
394
|
+
* (multiplexer) resize path never touches the alt buffer, breaking the loop.
|
|
395
|
+
*
|
|
396
|
+
* `PI_TUI_RESIZE_IN_PLACE=1|0` forces this on/off for any terminal.
|
|
397
|
+
*/
|
|
398
|
+
function reportsSizeOnAltScreenToggle(): boolean {
|
|
399
|
+
const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
|
|
400
|
+
if (override === "0" || override === "false") return false;
|
|
401
|
+
if (override === "1" || override === "true") return true;
|
|
402
|
+
return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Resize should repaint the visible window in place — no alternate-screen
|
|
407
|
+
* borrow, no ED3 scrollback rewrap — for multiplexer panes and for terminals
|
|
408
|
+
* that loop on alt-screen toggles. The tradeoff is identical to a multiplexer:
|
|
409
|
+
* scrollback above the window keeps its old wrap instead of being re-flowed.
|
|
410
|
+
*/
|
|
411
|
+
function resizeRepaintsInPlace(): boolean {
|
|
412
|
+
return isMultiplexerSession() || reportsSizeOnAltScreenToggle();
|
|
413
|
+
}
|
|
414
|
+
|
|
382
415
|
/**
|
|
383
416
|
* Options for overlay positioning and sizing.
|
|
384
417
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
@@ -1293,7 +1326,7 @@ export class TUI extends Container {
|
|
|
1293
1326
|
// `#resizeEventPending` is set first so the eventual render still
|
|
1294
1327
|
// classifies as a resize.
|
|
1295
1328
|
this.#resizeEventPending = true;
|
|
1296
|
-
if (!
|
|
1329
|
+
if (!resizeRepaintsInPlace()) {
|
|
1297
1330
|
// Enter the viewport fast path and (re)arm the settle timer, then
|
|
1298
1331
|
// request the cheap viewport-only paint. The authoritative full
|
|
1299
1332
|
// replay fires from the settle timer once the drag goes quiet.
|
|
@@ -2377,13 +2410,15 @@ export class TUI extends Container {
|
|
|
2377
2410
|
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
|
|
2378
2411
|
);
|
|
2379
2412
|
|
|
2380
|
-
// 4. Classify. A resize is an explicit user gesture:
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2413
|
+
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
|
2414
|
+
// erases and replays so history rewraps at the new geometry (the reader
|
|
2415
|
+
// snapped to the bottom just dragged the window). Multiplexer panes — and
|
|
2416
|
+
// terminals that re-report size on alt-screen toggles — instead repaint in
|
|
2417
|
+
// place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
|
|
2418
|
+
// feedback loop), so committed history keeps its old wrap.
|
|
2384
2419
|
const firstPaint = !this.#hasEverRendered;
|
|
2385
2420
|
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2386
|
-
const geometryRebuild = geometryChanged && !
|
|
2421
|
+
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
|
|
2387
2422
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
2388
2423
|
let windowTop: number;
|
|
2389
2424
|
let chunkTo: number;
|
|
@@ -2504,7 +2539,7 @@ export class TUI extends Container {
|
|
|
2504
2539
|
windowTop,
|
|
2505
2540
|
prevWindowTop,
|
|
2506
2541
|
prevHardwareCursorRow,
|
|
2507
|
-
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged &&
|
|
2542
|
+
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
|
|
2508
2543
|
});
|
|
2509
2544
|
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
2510
2545
|
this.#committedPrefix.push(rawFrame[i] ?? "");
|