@oh-my-pi/pi-tui 17.0.2 → 17.0.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 +10 -0
- package/README.md +1 -1
- package/dist/types/terminal.d.ts +2 -2
- package/package.json +3 -3
- package/src/components/markdown.ts +10 -3
- package/src/stdin-buffer.ts +122 -10
- package/src/terminal-capabilities.ts +6 -5
- package/src/terminal.ts +41 -7
- package/src/tui.ts +17 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.3] - 2026-07-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now collects adjacent ESC-free, CR/LF-bearing stdin reads in a fixed 10 ms classification window and coalesces three or more lines into one paste event, while ambiguous one-break input (including Enter batched with a following keystroke) is replayed unchanged ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)).
|
|
10
|
+
- Fixed wrapped OSC 8 links in Markdown tables making cell padding, separators, and adjacent cells clickable ([#5885](https://github.com/can1357/oh-my-pi/issues/5885)).
|
|
11
|
+
- Fixed interactive sessions surviving terminal closure and entering a runaway render loop by stopping the TUI and raising SIGHUP when terminal input closes or output fails ([#5835](https://github.com/can1357/oh-my-pi/issues/5835)).
|
|
12
|
+
- Fixed native cmux SSH pane resizes inserting blank rows into terminal scrollback by routing remote-transport sessions through the in-place repaint path ([#5857](https://github.com/can1357/oh-my-pi/issues/5857)).
|
|
13
|
+
- Fixed the terminal flickering when leaving a fullscreen overlay (e.g. `/settings`) on terminals that re-report their size when the alternate screen buffer toggles: the alt-toggle SIGWINCH echo is height-only, so the resize fast path no longer borrows the alternate screen for it ([#5854](https://github.com/can1357/oh-my-pi/issues/5854)).
|
|
14
|
+
|
|
5
15
|
## [17.0.2] - 2026-07-17
|
|
6
16
|
|
|
7
17
|
### Added
|
package/README.md
CHANGED
|
@@ -522,7 +522,7 @@ The TUI works with any object implementing the `Terminal` interface:
|
|
|
522
522
|
|
|
523
523
|
```typescript
|
|
524
524
|
interface Terminal {
|
|
525
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
525
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
526
526
|
stop(): void;
|
|
527
527
|
write(data: string): void;
|
|
528
528
|
get columns(): number;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare function emergencyTerminalRestore(): void;
|
|
|
31
31
|
/** Terminal-reported appearance (dark/light mode). */
|
|
32
32
|
export type TerminalAppearance = "dark" | "light";
|
|
33
33
|
export interface Terminal {
|
|
34
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
34
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
35
35
|
stop(): void;
|
|
36
36
|
/**
|
|
37
37
|
* Drain stdin before exiting to prevent Kitty key release events from
|
|
@@ -113,7 +113,7 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
113
113
|
*/
|
|
114
114
|
refreshAppearance(): void;
|
|
115
115
|
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
116
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
116
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
117
117
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
118
118
|
stop(): void;
|
|
119
119
|
write(data: string): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.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": "17.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.0.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.3",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
|
@@ -2190,8 +2190,8 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2190
2190
|
if (token.text === token.href || token.text === hrefForComparison)
|
|
2191
2191
|
result += clickableLinkText + stylePrefix;
|
|
2192
2192
|
else {
|
|
2193
|
-
const styledLinkUrl = this.#theme.linkUrl(`
|
|
2194
|
-
result += clickableLinkText
|
|
2193
|
+
const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
|
|
2194
|
+
result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
|
|
2195
2195
|
}
|
|
2196
2196
|
break;
|
|
2197
2197
|
}
|
|
@@ -2388,7 +2388,14 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2388
2388
|
*/
|
|
2389
2389
|
#wrapCellText(text: string, maxWidth: number): string[] {
|
|
2390
2390
|
const cellWidth = Math.max(1, maxWidth);
|
|
2391
|
-
|
|
2391
|
+
// Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
|
|
2392
|
+
// hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
|
|
2393
|
+
// per-fragment wrapping would drop the reopened link on later rows.
|
|
2394
|
+
const wrapped = wrapTextWithAnsi(text, cellWidth);
|
|
2395
|
+
while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") {
|
|
2396
|
+
wrapped.pop();
|
|
2397
|
+
}
|
|
2398
|
+
return wrapped;
|
|
2392
2399
|
}
|
|
2393
2400
|
|
|
2394
2401
|
/**
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -62,6 +62,39 @@ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
|
|
|
62
62
|
// runs at most once per resolved report — never inside the growth loop.
|
|
63
63
|
const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
|
|
64
64
|
|
|
65
|
+
// Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so
|
|
66
|
+
// adjacent stdin reads from one unmarked paste can be considered together.
|
|
67
|
+
// Fixed from the first break-bearing read (not an inactivity debounce): normal
|
|
68
|
+
// Enter latency and candidate memory remain bounded even under a continuous
|
|
69
|
+
// stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible.
|
|
70
|
+
const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `text` has two completed logical line breaks (three line segments).
|
|
74
|
+
*
|
|
75
|
+
* A single Enter may be batched with surrounding keystrokes in one stdin read,
|
|
76
|
+
* so one break is ambiguous and must stay on the key path. CRLF counts as one
|
|
77
|
+
* logical break. Content after the second break completes the third segment;
|
|
78
|
+
* until then the classification window keeps buffering.
|
|
79
|
+
*/
|
|
80
|
+
function isRawMultilineBurst(text: string): boolean {
|
|
81
|
+
let breaks = 0;
|
|
82
|
+
for (let i = 0; i < text.length; i++) {
|
|
83
|
+
const code = text.charCodeAt(i);
|
|
84
|
+
if (code === 0x0d) {
|
|
85
|
+
breaks++;
|
|
86
|
+
if (text.charCodeAt(i + 1) === 0x0a) i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (code === 0x0a) {
|
|
90
|
+
breaks++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (breaks >= 2) return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
/**
|
|
66
99
|
* Resolve the exclusive-end index of the escape sequence starting at `pos`
|
|
67
100
|
* (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
|
|
@@ -364,6 +397,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
364
397
|
#pendingKittyPrintableCodepoint: number | undefined;
|
|
365
398
|
#pendingKittyPrintableAtMs = 0;
|
|
366
399
|
#escapeSearchOffset = 0;
|
|
400
|
+
#rawPasteCandidate = "";
|
|
401
|
+
#rawPasteTimer?: NodeJS.Timeout;
|
|
367
402
|
|
|
368
403
|
constructor(options: StdinBufferOptions = {}) {
|
|
369
404
|
super();
|
|
@@ -398,20 +433,49 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
398
433
|
this.#clearFlushTimer();
|
|
399
434
|
}
|
|
400
435
|
|
|
401
|
-
if (str.length === 0 && this.#buffer.length === 0) {
|
|
436
|
+
if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) {
|
|
402
437
|
this.#emitDataSequence("");
|
|
403
438
|
return;
|
|
404
439
|
}
|
|
405
440
|
|
|
406
|
-
this.#buffer += str;
|
|
407
|
-
|
|
408
441
|
if (this.#pasteMode) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
442
|
+
this.#consumePasteChunk(str);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (this.#rawPasteCandidate.length > 0) {
|
|
447
|
+
if (str.indexOf(ESC) !== -1) {
|
|
448
|
+
// Escape-bearing input cannot belong to an unmarked raw paste.
|
|
449
|
+
// Replay the ambiguous prefix as keys before parsing the escape.
|
|
450
|
+
this.#flushRawPasteCandidate();
|
|
451
|
+
} else {
|
|
452
|
+
this.#rawPasteCandidate += str;
|
|
453
|
+
if (isRawMultilineBurst(this.#rawPasteCandidate)) {
|
|
454
|
+
this.#emitRawPasteCandidate();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
this.#buffer.length === 0 &&
|
|
462
|
+
str.indexOf(ESC) === -1 &&
|
|
463
|
+
(str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
|
|
464
|
+
) {
|
|
465
|
+
// Hold the first break-bearing read briefly. A split raw paste can
|
|
466
|
+
// then accumulate enough logical lines to classify; an ordinary
|
|
467
|
+
// Enter is replayed unchanged when the fixed window expires.
|
|
468
|
+
this.#rawPasteCandidate = str;
|
|
469
|
+
if (isRawMultilineBurst(str)) {
|
|
470
|
+
this.#emitRawPasteCandidate();
|
|
471
|
+
} else {
|
|
472
|
+
this.#armRawPasteTimer();
|
|
473
|
+
}
|
|
412
474
|
return;
|
|
413
475
|
}
|
|
414
476
|
|
|
477
|
+
this.#buffer += str;
|
|
478
|
+
|
|
415
479
|
const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
|
|
416
480
|
if (startIndex !== -1) {
|
|
417
481
|
if (startIndex > 0) {
|
|
@@ -526,6 +590,46 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
526
590
|
this.emit("paste", content);
|
|
527
591
|
}
|
|
528
592
|
|
|
593
|
+
/** Start one fixed window from the first break-bearing raw read. */
|
|
594
|
+
#armRawPasteTimer(): void {
|
|
595
|
+
if (this.#rawPasteTimer) return;
|
|
596
|
+
this.#rawPasteTimer = setTimeout(() => {
|
|
597
|
+
this.#rawPasteTimer = undefined;
|
|
598
|
+
this.#flushRawPasteCandidate();
|
|
599
|
+
}, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#clearRawPasteTimer(): void {
|
|
603
|
+
if (this.#rawPasteTimer) {
|
|
604
|
+
clearTimeout(this.#rawPasteTimer);
|
|
605
|
+
this.#rawPasteTimer = undefined;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#takeRawPasteCandidate(): string {
|
|
610
|
+
this.#clearRawPasteTimer();
|
|
611
|
+
const content = this.#rawPasteCandidate;
|
|
612
|
+
this.#rawPasteCandidate = "";
|
|
613
|
+
return content;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Emit a classified raw multiline burst through the paste channel. */
|
|
617
|
+
#emitRawPasteCandidate(): void {
|
|
618
|
+
const content = this.#takeRawPasteCandidate();
|
|
619
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
620
|
+
this.emit("paste", content);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Replay an ambiguous raw candidate as the original per-key data events. */
|
|
624
|
+
#flushRawPasteCandidate(): void {
|
|
625
|
+
const content = this.#takeRawPasteCandidate();
|
|
626
|
+
if (content.length === 0) return;
|
|
627
|
+
const result = extractCompleteSequences(content, 0);
|
|
628
|
+
for (const sequence of result.sequences) {
|
|
629
|
+
this.#emitDataSequence(sequence);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
529
633
|
#emitDataSequence(sequence: string): void {
|
|
530
634
|
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
531
635
|
if (
|
|
@@ -627,8 +731,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
627
731
|
flush(): string[] {
|
|
628
732
|
this.#clearFlushTimer();
|
|
629
733
|
|
|
734
|
+
const rawCandidate = this.#takeRawPasteCandidate();
|
|
735
|
+
const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : [];
|
|
736
|
+
|
|
630
737
|
if (this.#buffer.length === 0) {
|
|
631
|
-
|
|
738
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
739
|
+
return sequences;
|
|
632
740
|
}
|
|
633
741
|
|
|
634
742
|
const buffered = this.#buffer;
|
|
@@ -641,15 +749,19 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
641
749
|
// emission swallows the double-escape gesture (#3857). Mirror the inline
|
|
642
750
|
// split in `extractCompleteSequences` and deliver two ESC events.
|
|
643
751
|
if (buffered === `${ESC}${ESC}`) {
|
|
644
|
-
|
|
752
|
+
sequences.push(ESC, ESC);
|
|
753
|
+
} else {
|
|
754
|
+
sequences.push(buffered);
|
|
645
755
|
}
|
|
646
|
-
return
|
|
756
|
+
return sequences;
|
|
647
757
|
}
|
|
648
758
|
|
|
649
759
|
clear(): void {
|
|
650
760
|
this.#clearFlushTimer();
|
|
651
761
|
this.#clearPasteWatchdog();
|
|
762
|
+
this.#clearRawPasteTimer();
|
|
652
763
|
this.#buffer = "";
|
|
764
|
+
this.#rawPasteCandidate = "";
|
|
653
765
|
this.#pasteMode = false;
|
|
654
766
|
this.#pasteChunks = [];
|
|
655
767
|
this.#pasteOverlap = "";
|
|
@@ -660,7 +772,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
660
772
|
}
|
|
661
773
|
|
|
662
774
|
getBuffer(): string {
|
|
663
|
-
return this.#buffer
|
|
775
|
+
return `${this.#rawPasteCandidate}${this.#buffer}`;
|
|
664
776
|
}
|
|
665
777
|
|
|
666
778
|
destroy(): void {
|
|
@@ -180,12 +180,13 @@ export class TerminalInfo {
|
|
|
180
180
|
|
|
181
181
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
182
182
|
export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
183
|
-
// TMUX/STY/ZELLIJ
|
|
184
|
-
// signals. TERM can also survive when those are
|
|
185
|
-
// `su`, env-sanitizing launchers/ssh). Do not
|
|
186
|
-
// a CLI socket override and can be set
|
|
183
|
+
// TMUX/STY/ZELLIJ and CMUX workspace/surface/remote-transport markers are
|
|
184
|
+
// authoritative session signals. TERM can also survive when those are
|
|
185
|
+
// stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
|
|
186
|
+
// use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
|
|
187
|
+
// outside a CMUX terminal.
|
|
187
188
|
if (env.TMUX || env.STY || env.ZELLIJ) return true;
|
|
188
|
-
if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID) return true;
|
|
189
|
+
if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
|
|
189
190
|
const term = env.TERM?.toLowerCase() ?? "";
|
|
190
191
|
return term.startsWith("tmux") || term.startsWith("screen");
|
|
191
192
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -337,8 +337,8 @@ export function emergencyTerminalRestore(): void {
|
|
|
337
337
|
/** Terminal-reported appearance (dark/light mode). */
|
|
338
338
|
export type TerminalAppearance = "dark" | "light";
|
|
339
339
|
export interface Terminal {
|
|
340
|
-
// Start the terminal with input and
|
|
341
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
340
|
+
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
341
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
342
342
|
|
|
343
343
|
// Stop the terminal and restore state
|
|
344
344
|
stop(): void;
|
|
@@ -483,6 +483,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
483
483
|
#modifyOtherKeysTimeout?: Timer;
|
|
484
484
|
#stdinBuffer?: StdinBuffer;
|
|
485
485
|
#stdinDataHandler?: (data: string) => void;
|
|
486
|
+
#disconnectHandler?: () => void;
|
|
487
|
+
#stdinEndHandler = () => {
|
|
488
|
+
this.#markTerminalDisconnected("stdin ended");
|
|
489
|
+
};
|
|
490
|
+
#stdinCloseHandler = () => {
|
|
491
|
+
this.#markTerminalDisconnected("stdin closed");
|
|
492
|
+
};
|
|
493
|
+
#stdinErrorHandler = (err: Error) => {
|
|
494
|
+
this.#markTerminalDisconnected("stdin failed", err);
|
|
495
|
+
};
|
|
486
496
|
#dead = false;
|
|
487
497
|
// Captured at construction and re-read at start(): when true, every real
|
|
488
498
|
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
|
|
@@ -491,7 +501,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
491
501
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
492
502
|
#stdoutErrorCleanup?: () => void;
|
|
493
503
|
#stdoutErrorHandler = (err: Error) => {
|
|
494
|
-
this.#
|
|
504
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
495
505
|
};
|
|
496
506
|
|
|
497
507
|
#windowsVTInputRestore?: () => void;
|
|
@@ -577,9 +587,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
577
587
|
this.#privateModeCallbacks.push(callback);
|
|
578
588
|
}
|
|
579
589
|
|
|
580
|
-
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
590
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
|
|
581
591
|
this.#inputHandler = onInput;
|
|
582
592
|
this.#resizeHandler = onResize;
|
|
593
|
+
this.#disconnectHandler = onDisconnect;
|
|
583
594
|
|
|
584
595
|
// Headless (tests): suppress every real-terminal side effect. Skip raw
|
|
585
596
|
// mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
|
|
@@ -604,6 +615,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
604
615
|
process.stdin.setRawMode(true);
|
|
605
616
|
}
|
|
606
617
|
process.stdin.setEncoding("utf8");
|
|
618
|
+
process.stdin.on("end", this.#stdinEndHandler);
|
|
619
|
+
process.stdin.on("close", this.#stdinCloseHandler);
|
|
620
|
+
process.stdin.on("error", this.#stdinErrorHandler);
|
|
607
621
|
process.stdin.resume();
|
|
608
622
|
|
|
609
623
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
@@ -1425,6 +1439,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
1425
1439
|
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
1426
1440
|
this.#stdinDataHandler = undefined;
|
|
1427
1441
|
}
|
|
1442
|
+
process.stdin.removeListener("end", this.#stdinEndHandler);
|
|
1443
|
+
process.stdin.removeListener("close", this.#stdinCloseHandler);
|
|
1444
|
+
process.stdin.removeListener("error", this.#stdinErrorHandler);
|
|
1445
|
+
this.#disconnectHandler = undefined;
|
|
1428
1446
|
this.#inputHandler = undefined;
|
|
1429
1447
|
this.#appearance = undefined;
|
|
1430
1448
|
if (this.#stdoutResizeListener) {
|
|
@@ -1450,10 +1468,26 @@ export class ProcessTerminal implements Terminal {
|
|
|
1450
1468
|
this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
|
|
1451
1469
|
}
|
|
1452
1470
|
|
|
1453
|
-
#
|
|
1471
|
+
#markTerminalDisconnected(reason: string, err?: unknown): void {
|
|
1454
1472
|
if (this.#dead) return;
|
|
1455
1473
|
this.#dead = true;
|
|
1456
|
-
logger.warn("terminal
|
|
1474
|
+
logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
|
|
1475
|
+
|
|
1476
|
+
const disconnectHandler = this.#disconnectHandler;
|
|
1477
|
+
this.#disconnectHandler = undefined;
|
|
1478
|
+
if (!disconnectHandler) return;
|
|
1479
|
+
disconnectHandler();
|
|
1480
|
+
|
|
1481
|
+
if (process.platform === "win32") {
|
|
1482
|
+
void postmortem.quit(129);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
process.kill(process.pid, "SIGHUP");
|
|
1487
|
+
} catch (signalErr) {
|
|
1488
|
+
logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr });
|
|
1489
|
+
void postmortem.quit(129);
|
|
1490
|
+
}
|
|
1457
1491
|
}
|
|
1458
1492
|
|
|
1459
1493
|
write(data: string): void {
|
|
@@ -1500,7 +1534,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1500
1534
|
process.stdout.write(data);
|
|
1501
1535
|
}
|
|
1502
1536
|
} catch (err) {
|
|
1503
|
-
this.#
|
|
1537
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
1504
1538
|
}
|
|
1505
1539
|
}
|
|
1506
1540
|
|
package/src/tui.ts
CHANGED
|
@@ -1571,7 +1571,9 @@ export class TUI extends Container {
|
|
|
1571
1571
|
}
|
|
1572
1572
|
this.#armMultiplexerResizeTimer(false);
|
|
1573
1573
|
},
|
|
1574
|
+
() => this.stop(),
|
|
1574
1575
|
);
|
|
1576
|
+
if (this.#stopped) return;
|
|
1575
1577
|
for (const listener of this.#startListeners) {
|
|
1576
1578
|
try {
|
|
1577
1579
|
listener();
|
|
@@ -3725,15 +3727,23 @@ export class TUI extends Container {
|
|
|
3725
3727
|
}
|
|
3726
3728
|
|
|
3727
3729
|
/**
|
|
3728
|
-
* Emit a throwaway viewport repaint for the resize fast path as
|
|
3729
|
-
*
|
|
3730
|
-
* width
|
|
3731
|
-
* screen
|
|
3732
|
-
* fragments into native scrollback.
|
|
3733
|
-
*
|
|
3730
|
+
* Emit a throwaway viewport repaint for the resize fast path as a per-row
|
|
3731
|
+
* overwrite. A width change can make the terminal's normal buffer reflow
|
|
3732
|
+
* full-width rows before the app repaints, so a width drag borrows the
|
|
3733
|
+
* alternate screen: transient resizes truncate the viewport instead of
|
|
3734
|
+
* pushing wrapped fragments into native scrollback. A height-only resize
|
|
3735
|
+
* reflows nothing, so it repaints the normal screen in place — borrowing the
|
|
3736
|
+
* alt buffer there is pure flicker, and on terminals that re-report their
|
|
3737
|
+
* size when the alt buffer toggles it is self-sustaining: leaving a
|
|
3738
|
+
* fullscreen overlay's alt screen fires a height-only SIGWINCH echo, which
|
|
3739
|
+
* would otherwise re-borrow the alt buffer for one frame (the settings-exit
|
|
3740
|
+
* flash, #5854). Normal-screen history is rebuilt once at settle via
|
|
3741
|
+
* `#emitFullPaint`.
|
|
3734
3742
|
*/
|
|
3735
3743
|
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3736
|
-
|
|
3744
|
+
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3745
|
+
const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
|
|
3746
|
+
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
|
3737
3747
|
for (let r = 0; r < height; r++) {
|
|
3738
3748
|
if (r > 0) buffer += "\r\n";
|
|
3739
3749
|
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|