@oh-my-pi/pi-tui 16.0.0 → 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 +20 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +30 -29
- package/src/keys.ts +31 -7
- package/src/terminal.ts +45 -8
- package/src/ttyid.ts +14 -0
- package/src/tui.ts +46 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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
|
+
|
|
14
|
+
## [16.0.1] - 2026-06-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Added Zellij and WezTerm pane environment fallbacks for terminal-specific session continuation when no TTY path is available.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed slash command autocomplete acceptance replacing only a stale rendered prefix, which could leave fast-typed characters before `/skills:` completions and corrupt the submitted command ([#1745](https://github.com/can1357/oh-my-pi/issues/1745)).
|
|
23
|
+
|
|
5
24
|
## [15.13.1] - 2026-06-15
|
|
6
25
|
|
|
7
26
|
### Added
|
|
@@ -382,7 +401,7 @@
|
|
|
382
401
|
|
|
383
402
|
### Changed
|
|
384
403
|
|
|
385
|
-
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
|
|
404
|
+
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe ([#1799](https://github.com/can1357/oh-my-pi/issues/1799)).
|
|
386
405
|
- Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
|
|
387
406
|
|
|
388
407
|
### Fixed
|
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/autocomplete.ts
CHANGED
|
@@ -413,27 +413,39 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
413
413
|
prefix: string,
|
|
414
414
|
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
|
415
415
|
const currentLine = lines[cursorLine] || "";
|
|
416
|
-
const
|
|
416
|
+
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
417
417
|
const afterCursor = currentLine.slice(cursorCol);
|
|
418
418
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
|
|
422
|
-
if (isSlashCommand) {
|
|
423
|
-
// This is a command name completion
|
|
424
|
-
const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
|
|
425
|
-
const newLines = [...lines];
|
|
426
|
-
newLines[cursorLine] = newLine;
|
|
419
|
+
const slashStart = textBeforeCursor.indexOf("/");
|
|
420
|
+
const hasOnlyWhitespaceBeforeSlash = slashStart >= 0 && textBeforeCursor.slice(0, slashStart).trim() === "";
|
|
427
421
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
422
|
+
// Slash command suggestions can be accepted before the debounced refresh
|
|
423
|
+
// catches up to newly typed characters. Replace the live command token,
|
|
424
|
+
// not only the prefix captured when the suggestion list was rendered.
|
|
425
|
+
if (prefix.startsWith("/") && hasOnlyWhitespaceBeforeSlash) {
|
|
426
|
+
const slashPrefix = textBeforeCursor.slice(slashStart);
|
|
427
|
+
if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
|
|
428
|
+
const beforeSlash = currentLine.slice(0, slashStart);
|
|
429
|
+
const newLine = `${beforeSlash}/${item.value} ${afterCursor}`;
|
|
430
|
+
const newLines = [...lines];
|
|
431
|
+
newLines[cursorLine] = newLine;
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
lines: newLines,
|
|
435
|
+
cursorLine,
|
|
436
|
+
cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space
|
|
437
|
+
};
|
|
438
|
+
}
|
|
433
439
|
}
|
|
434
440
|
|
|
441
|
+
let beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
|
442
|
+
|
|
435
443
|
// Check if we're completing a file attachment (prefix starts with "@")
|
|
436
444
|
if (prefix.startsWith("@")) {
|
|
445
|
+
const liveAtPrefix = this.#extractAtPrefix(textBeforeCursor);
|
|
446
|
+
if (liveAtPrefix) {
|
|
447
|
+
beforePrefix = currentLine.slice(0, cursorCol - liveAtPrefix.length);
|
|
448
|
+
}
|
|
437
449
|
// This is a file attachment completion
|
|
438
450
|
const newLine = `${beforePrefix + item.value} ${afterCursor}`;
|
|
439
451
|
const newLines = [...lines];
|
|
@@ -446,21 +458,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
446
458
|
};
|
|
447
459
|
}
|
|
448
460
|
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const newLine = beforePrefix + item.value + afterCursor;
|
|
454
|
-
const newLines = [...lines];
|
|
455
|
-
newLines[cursorLine] = newLine;
|
|
456
|
-
|
|
457
|
-
return {
|
|
458
|
-
lines: newLines,
|
|
459
|
-
cursorLine,
|
|
460
|
-
cursorCol: beforePrefix.length + item.value.length,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
461
|
+
// Slash command argument and plain file path completion both fall through
|
|
462
|
+
// to the path-completion tail below — `beforePrefix` already covers the
|
|
463
|
+
// rendered prefix, which preserves earlier arguments (e.g. accepting
|
|
464
|
+
// `package.json` for `/swarm run pac<Tab>` keeps the `run` token intact).
|
|
464
465
|
// For file paths, complete the path
|
|
465
466
|
const newLine = beforePrefix + item.value + afterCursor;
|
|
466
467
|
const newLines = [...lines];
|
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/ttyid.ts
CHANGED
|
@@ -51,15 +51,29 @@ export function getTerminalId(): string | null {
|
|
|
51
51
|
|
|
52
52
|
// Fallback to terminal-specific env vars
|
|
53
53
|
// Prefer inner multiplexers over host terminal emulators when stdin has no TTY path.
|
|
54
|
+
const zellijPane = process.env.ZELLIJ_PANE_ID;
|
|
55
|
+
if (zellijPane) {
|
|
56
|
+
// Session names are user-chosen (`zellij -s …`) and the id is used as a
|
|
57
|
+
// breadcrumb filename — normalize path separators like the TTY branch does.
|
|
58
|
+
const zellijSession = process.env.ZELLIJ_SESSION_NAME?.replace(/[\\/]/g, "-");
|
|
59
|
+
return zellijSession ? `zellij-${zellijSession}-${zellijPane}` : `zellij-${zellijPane}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
const tmuxPane = process.env.TMUX_PANE;
|
|
55
63
|
if (tmuxPane) return `tmux-${tmuxPane}`;
|
|
56
64
|
|
|
57
65
|
const cmuxSurface = process.env.CMUX_SURFACE_ID;
|
|
58
66
|
if (cmuxSurface) return `cmux-${cmuxSurface}`;
|
|
59
67
|
|
|
68
|
+
// Kitty before WezTerm/others, matching terminal-capabilities.ts detection
|
|
69
|
+
// order. Inherited env makes either order wrong for some nesting; staying
|
|
70
|
+
// consistent with the capability detector keeps the two answers aligned.
|
|
60
71
|
const kittyId = process.env.KITTY_WINDOW_ID;
|
|
61
72
|
if (kittyId) return `kitty-${kittyId}`;
|
|
62
73
|
|
|
74
|
+
const weztermPane = process.env.WEZTERM_PANE;
|
|
75
|
+
if (weztermPane) return `wezterm-${weztermPane}`;
|
|
76
|
+
|
|
63
77
|
const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
|
|
64
78
|
if (terminalSessionId) return `apple-${terminalSessionId}`;
|
|
65
79
|
|
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] ?? "");
|