@oh-my-pi/pi-tui 17.1.8 → 17.2.0
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 +13 -0
- package/dist/types/terminal.d.ts +40 -1
- package/package.json +3 -3
- package/src/components/loader.ts +44 -18
- package/src/components/markdown.ts +6 -5
- package/src/terminal.ts +129 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.0] - 2026-07-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added response-level OSC 11 appearance subscriptions to help terminal consumers distinguish confirmed unchanged background classifications from missing replies.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed native Windows terminal panes freezing their host during forced closure by skipping the stdout-drain wait after ConPTY disconnects.
|
|
14
|
+
- Fixed high CPU usage in the Loader spinner during idle waits by optimizing text wrapping and caching during frame updates.
|
|
15
|
+
- Fixed hash-prefixed UUIDs in prose being misclassified as 8-digit CSS colors and receiving spurious swatches.
|
|
16
|
+
- Fixed unbounded memory growth and potential host freezes when a PTY consumer stalls by capping the pending stdout backlog and treating undrained consumers as a disconnect.
|
|
17
|
+
|
|
5
18
|
## [17.1.8] - 2026-07-28
|
|
6
19
|
|
|
7
20
|
### Fixed
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -19,6 +19,36 @@
|
|
|
19
19
|
* sole production caller.
|
|
20
20
|
*/
|
|
21
21
|
export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Turns an unbounded, never-draining stdout writable buffer into a bounded
|
|
24
|
+
* disconnect signal.
|
|
25
|
+
*
|
|
26
|
+
* `process.stdout.write()` returns `false` once its buffer exceeds the stream
|
|
27
|
+
* high-water mark; the bytes stay queued and are only freed when the consumer
|
|
28
|
+
* drains (the `drain` event). While the consumer keeps up, writes are accepted
|
|
29
|
+
* and nothing accumulates. When it stalls, every subsequent write piles onto
|
|
30
|
+
* the buffer — a stalled-but-alive PTY reader never throws, so the write path
|
|
31
|
+
* has no other signal that output is going nowhere. This guard sums the bytes
|
|
32
|
+
* queued since backpressure began and reports when that backlog crosses the
|
|
33
|
+
* cap, at which point the caller treats the terminal as disconnected.
|
|
34
|
+
*
|
|
35
|
+
* Exported for unit testing; `ProcessTerminal` is the sole production user.
|
|
36
|
+
*/
|
|
37
|
+
export declare class OutputBacklogGuard {
|
|
38
|
+
#private;
|
|
39
|
+
private readonly capBytes;
|
|
40
|
+
constructor(capBytes?: number);
|
|
41
|
+
/** True once a refused write started a backlog that has not yet drained. */
|
|
42
|
+
get tracking(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Record one `stdout.write()`: `accepted` is that call's return value and
|
|
45
|
+
* `bytes` its encoded size. Returns true when the pending backlog now
|
|
46
|
+
* exceeds the cap and the terminal should be treated as disconnected.
|
|
47
|
+
*/
|
|
48
|
+
record(accepted: boolean, bytes: number): boolean;
|
|
49
|
+
/** Called on the stdout `drain` event: the buffer emptied, backlog cleared. */
|
|
50
|
+
reset(): void;
|
|
51
|
+
}
|
|
22
52
|
/** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
|
|
23
53
|
export declare function setAltScreenActive(active: boolean): void;
|
|
24
54
|
/**
|
|
@@ -61,6 +91,13 @@ export interface Terminal {
|
|
|
61
91
|
* already-detected appearance so late subscribers never miss it.
|
|
62
92
|
*/
|
|
63
93
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
94
|
+
/**
|
|
95
|
+
* Register a callback fired for every valid OSC 11 appearance report,
|
|
96
|
+
* including reports whose classification matches the current appearance.
|
|
97
|
+
* Unlike onAppearanceChange, this does not replay an earlier report.
|
|
98
|
+
* Optional so custom Terminals built against older pi-tui versions keep working.
|
|
99
|
+
*/
|
|
100
|
+
onAppearanceReport?(callback: (appearance: TerminalAppearance) => void): (() => void) | void;
|
|
64
101
|
/**
|
|
65
102
|
* Issue a single OSC 11 background-color re-query, driving the appearance
|
|
66
103
|
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
@@ -101,13 +138,15 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
101
138
|
get keyboardEnhancementExitSequence(): string | null;
|
|
102
139
|
get appearance(): TerminalAppearance | undefined;
|
|
103
140
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
141
|
+
onAppearanceReport(callback: (appearance: TerminalAppearance) => void): () => void;
|
|
104
142
|
/**
|
|
105
143
|
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
106
144
|
* startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
|
|
107
145
|
* appearance callbacks. Inside tmux, only this explicit path wraps the query
|
|
108
146
|
* and sentinel together for passthrough to the outer terminal; startup and
|
|
109
147
|
* Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
|
|
110
|
-
* armed. Suppressed while headless or after the terminal is torn
|
|
148
|
+
* armed. Suppressed while inactive, headless, or after the terminal is torn
|
|
149
|
+
* down.
|
|
111
150
|
*/
|
|
112
151
|
refreshAppearance(): void;
|
|
113
152
|
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): 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.
|
|
4
|
+
"version": "17.2.0",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.2.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.2.0",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
package/src/components/loader.ts
CHANGED
|
@@ -24,6 +24,8 @@ export class Loader extends Text {
|
|
|
24
24
|
#lastSpinnerTick = 0;
|
|
25
25
|
#layoutSource?: readonly string[];
|
|
26
26
|
#layout?: readonly { leading: string; content: string; trailing: string }[];
|
|
27
|
+
#layoutFrames: readonly string[];
|
|
28
|
+
#layoutFrame: string;
|
|
27
29
|
|
|
28
30
|
constructor(
|
|
29
31
|
ui: TUI,
|
|
@@ -37,6 +39,17 @@ export class Loader extends Text {
|
|
|
37
39
|
if (spinnerFrames && spinnerFrames.length > 0) {
|
|
38
40
|
this.#frames = spinnerFrames;
|
|
39
41
|
}
|
|
42
|
+
const representatives = new Map<number, string>();
|
|
43
|
+
this.#layoutFrames = this.#frames.map(frame => {
|
|
44
|
+
const width = visibleWidth(frame);
|
|
45
|
+
const representative = representatives.get(width);
|
|
46
|
+
if (representative !== undefined) {
|
|
47
|
+
return representative;
|
|
48
|
+
}
|
|
49
|
+
representatives.set(width, frame);
|
|
50
|
+
return frame;
|
|
51
|
+
});
|
|
52
|
+
this.#layoutFrame = this.#layoutFrames[0];
|
|
40
53
|
this.start();
|
|
41
54
|
}
|
|
42
55
|
|
|
@@ -58,12 +71,16 @@ export class Loader extends Text {
|
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
const frame = this.#frames[this.#currentFrame];
|
|
74
|
+
// The wrapped text carries one stable representative per frame width.
|
|
75
|
+
// Same-width frames swap only the visible glyph here; crossing widths
|
|
76
|
+
// rewraps against the representative selected by #syncText.
|
|
77
|
+
const sentinel = this.#layoutFrame;
|
|
61
78
|
const lines = [""];
|
|
62
79
|
const layout = this.#layout ?? [];
|
|
63
80
|
for (let i = 0; i < layout.length; i++) {
|
|
64
81
|
const { leading, content, trailing } = layout[i];
|
|
65
|
-
if (i === 0 && content.startsWith(
|
|
66
|
-
const remainder = content.slice(
|
|
82
|
+
if (i === 0 && content.startsWith(sentinel)) {
|
|
83
|
+
const remainder = content.slice(sentinel.length);
|
|
67
84
|
const separator = remainder.startsWith(" ") ? " " : "";
|
|
68
85
|
const message = remainder.slice(separator.length);
|
|
69
86
|
lines.push(
|
|
@@ -78,7 +95,8 @@ export class Loader extends Text {
|
|
|
78
95
|
|
|
79
96
|
start() {
|
|
80
97
|
this.#lastSpinnerTick = performance.now();
|
|
81
|
-
this.#
|
|
98
|
+
this.#syncText();
|
|
99
|
+
this.#requestPaint();
|
|
82
100
|
const intervalMs = this.messageColorFn.animated === true ? RENDER_INTERVAL_MS : SPINNER_ADVANCE_MS;
|
|
83
101
|
this.#intervalId = setInterval(() => {
|
|
84
102
|
const now = performance.now();
|
|
@@ -88,9 +106,10 @@ export class Loader extends Text {
|
|
|
88
106
|
const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
|
|
89
107
|
this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
|
|
90
108
|
this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
|
|
109
|
+
this.#syncText();
|
|
91
110
|
}
|
|
92
111
|
if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) {
|
|
93
|
-
this.#
|
|
112
|
+
this.#requestPaint();
|
|
94
113
|
}
|
|
95
114
|
}, intervalMs);
|
|
96
115
|
}
|
|
@@ -112,22 +131,29 @@ export class Loader extends Text {
|
|
|
112
131
|
return;
|
|
113
132
|
}
|
|
114
133
|
this.message = message;
|
|
115
|
-
this.#
|
|
134
|
+
this.#syncText();
|
|
135
|
+
this.#requestPaint();
|
|
116
136
|
}
|
|
117
137
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
/** Re-wrap the underlying Text only when its message or frame width changes. */
|
|
139
|
+
#syncText(): boolean {
|
|
140
|
+
const layoutFrame = this.#layoutFrames[this.#currentFrame];
|
|
141
|
+
this.#layoutFrame = layoutFrame;
|
|
142
|
+
return this.setText(`${layoutFrame} ${this.message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#requestPaint() {
|
|
146
|
+
if (!this.#ui) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Direct write: a loader tick changes only this component, so the TUI can
|
|
150
|
+
// update the already-positioned rows without driving the full
|
|
151
|
+
// compose/prepare/diff pipeline. Lightweight test stubs may not carry the
|
|
152
|
+
// newer API; keep their legacy component-scoped path working.
|
|
153
|
+
if (typeof this.#ui.requestDirectWrite === "function") {
|
|
154
|
+
this.#ui.requestDirectWrite(this);
|
|
155
|
+
} else {
|
|
156
|
+
this.#ui.requestComponentRender(this);
|
|
131
157
|
}
|
|
132
158
|
}
|
|
133
159
|
}
|
|
@@ -1284,11 +1284,12 @@ function collapseInlineHtml(tokens: Token[]): Token[] {
|
|
|
1284
1284
|
const DEFAULT_COLOR_SWATCH_GLYPH = "■";
|
|
1285
1285
|
|
|
1286
1286
|
// `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
|
|
1287
|
-
// entities like ☃ and paths like foo#fff)
|
|
1288
|
-
// (so over-long runs never produce a
|
|
1289
|
-
// are enforced in classifyHexColor
|
|
1290
|
-
// 3, 6, or 8".
|
|
1291
|
-
const HEX_COLOR_REGEX =
|
|
1287
|
+
// entities like ☃ and paths like foo#fff), not the start of a canonical
|
|
1288
|
+
// UUID, and not trailed by more hex (so over-long runs never produce a
|
|
1289
|
+
// misleading swatch). Length/letter rules are enforced in classifyHexColor
|
|
1290
|
+
// since the alternation can't express "exactly 3, 6, or 8".
|
|
1291
|
+
const HEX_COLOR_REGEX =
|
|
1292
|
+
/(?<![\w#&])#(?![0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
|
|
1292
1293
|
const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
|
|
1293
1294
|
|
|
1294
1295
|
/**
|
package/src/terminal.ts
CHANGED
|
@@ -140,6 +140,66 @@ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_
|
|
|
140
140
|
return chunks;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Hard cap on bytes queued to a stalled stdout before its consumer is declared
|
|
145
|
+
* gone. A live terminal drains within milliseconds, so a backlog this large —
|
|
146
|
+
* far above any legitimate paint (a full session resume is a few MiB) — means
|
|
147
|
+
* the PTY reader has stopped consuming entirely. Without the cap, `#safeWrite`
|
|
148
|
+
* keeps handing cosmetic frames (the `hub wait` spinner, 500 ms progress
|
|
149
|
+
* snapshots) to a writable buffer that never drains, growing RSS without bound
|
|
150
|
+
* until the host runs out of memory. See #6854.
|
|
151
|
+
*/
|
|
152
|
+
const MAX_STDOUT_BACKLOG_BYTES = 64 * 1024 * 1024;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Turns an unbounded, never-draining stdout writable buffer into a bounded
|
|
156
|
+
* disconnect signal.
|
|
157
|
+
*
|
|
158
|
+
* `process.stdout.write()` returns `false` once its buffer exceeds the stream
|
|
159
|
+
* high-water mark; the bytes stay queued and are only freed when the consumer
|
|
160
|
+
* drains (the `drain` event). While the consumer keeps up, writes are accepted
|
|
161
|
+
* and nothing accumulates. When it stalls, every subsequent write piles onto
|
|
162
|
+
* the buffer — a stalled-but-alive PTY reader never throws, so the write path
|
|
163
|
+
* has no other signal that output is going nowhere. This guard sums the bytes
|
|
164
|
+
* queued since backpressure began and reports when that backlog crosses the
|
|
165
|
+
* cap, at which point the caller treats the terminal as disconnected.
|
|
166
|
+
*
|
|
167
|
+
* Exported for unit testing; `ProcessTerminal` is the sole production user.
|
|
168
|
+
*/
|
|
169
|
+
export class OutputBacklogGuard {
|
|
170
|
+
#bytes = 0;
|
|
171
|
+
#tracking = false;
|
|
172
|
+
|
|
173
|
+
constructor(private readonly capBytes: number = MAX_STDOUT_BACKLOG_BYTES) {}
|
|
174
|
+
|
|
175
|
+
/** True once a refused write started a backlog that has not yet drained. */
|
|
176
|
+
get tracking(): boolean {
|
|
177
|
+
return this.#tracking;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Record one `stdout.write()`: `accepted` is that call's return value and
|
|
182
|
+
* `bytes` its encoded size. Returns true when the pending backlog now
|
|
183
|
+
* exceeds the cap and the terminal should be treated as disconnected.
|
|
184
|
+
*/
|
|
185
|
+
record(accepted: boolean, bytes: number): boolean {
|
|
186
|
+
if (!this.#tracking) {
|
|
187
|
+
// Consumer is keeping up; nothing is queued.
|
|
188
|
+
if (accepted) return false;
|
|
189
|
+
// First refused write: backpressure has begun.
|
|
190
|
+
this.#tracking = true;
|
|
191
|
+
}
|
|
192
|
+
this.#bytes += bytes;
|
|
193
|
+
return this.#bytes > this.capBytes;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Called on the stdout `drain` event: the buffer emptied, backlog cleared. */
|
|
197
|
+
reset(): void {
|
|
198
|
+
this.#bytes = 0;
|
|
199
|
+
this.#tracking = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
143
203
|
/**
|
|
144
204
|
* Minimal terminal interface for TUI
|
|
145
205
|
*/
|
|
@@ -385,6 +445,13 @@ export interface Terminal {
|
|
|
385
445
|
* already-detected appearance so late subscribers never miss it.
|
|
386
446
|
*/
|
|
387
447
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
448
|
+
/**
|
|
449
|
+
* Register a callback fired for every valid OSC 11 appearance report,
|
|
450
|
+
* including reports whose classification matches the current appearance.
|
|
451
|
+
* Unlike onAppearanceChange, this does not replay an earlier report.
|
|
452
|
+
* Optional so custom Terminals built against older pi-tui versions keep working.
|
|
453
|
+
*/
|
|
454
|
+
onAppearanceReport?(callback: (appearance: TerminalAppearance) => void): (() => void) | void;
|
|
388
455
|
/**
|
|
389
456
|
* Issue a single OSC 11 background-color re-query, driving the appearance
|
|
390
457
|
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
@@ -478,6 +545,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
478
545
|
this.#markTerminalDisconnected("stdin failed", err);
|
|
479
546
|
};
|
|
480
547
|
#dead = false;
|
|
548
|
+
#active = false;
|
|
481
549
|
// Last cursor visibility written to the terminal, sniffed from every
|
|
482
550
|
// outgoing sequence (frame buffers embed their own ?25h/?25l), so
|
|
483
551
|
// hideCursor()/showCursor() can skip same-state writes. `undefined` =
|
|
@@ -493,10 +561,21 @@ export class ProcessTerminal implements Terminal {
|
|
|
493
561
|
#stdoutErrorHandler = (err: Error) => {
|
|
494
562
|
this.#markTerminalDisconnected("stdout failed", err);
|
|
495
563
|
};
|
|
564
|
+
// Bounds the stdout writable buffer against a stalled PTY consumer: a
|
|
565
|
+
// stalled-but-alive reader never throws, so #safeWrite has no error to catch
|
|
566
|
+
// and the writable buffer grows without bound as cosmetic frames pile up.
|
|
567
|
+
// See OutputBacklogGuard and #6854.
|
|
568
|
+
#stdoutBacklog = new OutputBacklogGuard();
|
|
569
|
+
#stdoutDrainArmed = false;
|
|
570
|
+
#stdoutDrainHandler = () => {
|
|
571
|
+
this.#stdoutDrainArmed = false;
|
|
572
|
+
this.#stdoutBacklog.reset();
|
|
573
|
+
};
|
|
496
574
|
|
|
497
575
|
#windowsVTInputRestore?: () => void;
|
|
498
576
|
#xtermScrollToBottomRestoreModes = new Set<number>();
|
|
499
577
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
578
|
+
#appearanceReportCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
500
579
|
#appearance: TerminalAppearance | undefined;
|
|
501
580
|
#osc11Pending = false;
|
|
502
581
|
#osc11QueuedRoute?: Osc11QueryRoute;
|
|
@@ -560,16 +639,28 @@ export class ProcessTerminal implements Terminal {
|
|
|
560
639
|
}
|
|
561
640
|
}
|
|
562
641
|
|
|
642
|
+
onAppearanceReport(callback: (appearance: TerminalAppearance) => void): () => void {
|
|
643
|
+
this.#appearanceReportCallbacks.push(callback);
|
|
644
|
+
let subscribed = true;
|
|
645
|
+
return () => {
|
|
646
|
+
if (!subscribed) return;
|
|
647
|
+
subscribed = false;
|
|
648
|
+
const index = this.#appearanceReportCallbacks.indexOf(callback);
|
|
649
|
+
if (index !== -1) this.#appearanceReportCallbacks.splice(index, 1);
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
563
653
|
/**
|
|
564
654
|
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
565
655
|
* startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
|
|
566
656
|
* appearance callbacks. Inside tmux, only this explicit path wraps the query
|
|
567
657
|
* and sentinel together for passthrough to the outer terminal; startup and
|
|
568
658
|
* Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
|
|
569
|
-
* armed. Suppressed while headless or after the terminal is torn
|
|
659
|
+
* armed. Suppressed while inactive, headless, or after the terminal is torn
|
|
660
|
+
* down.
|
|
570
661
|
*/
|
|
571
662
|
refreshAppearance(): void {
|
|
572
|
-
if (this.#headless || this.#dead) return;
|
|
663
|
+
if (!this.#active || this.#headless || this.#dead) return;
|
|
573
664
|
this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct");
|
|
574
665
|
}
|
|
575
666
|
|
|
@@ -651,6 +742,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
651
742
|
// The query handler intercepts input temporarily, then installs the user's handler
|
|
652
743
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
653
744
|
this.#queryAndEnableKittyProtocol();
|
|
745
|
+
// Explicit probes are safe only after their response parser and stdin
|
|
746
|
+
// data handler are installed. Keep this false throughout temporary stops.
|
|
747
|
+
this.#active = true;
|
|
654
748
|
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
655
749
|
|
|
656
750
|
// Query terminal background color via OSC 11 for dark/light detection.
|
|
@@ -1152,8 +1246,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
1152
1246
|
};
|
|
1153
1247
|
const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
|
|
1154
1248
|
const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
|
|
1155
|
-
|
|
1249
|
+
const changed = mode !== this.#appearance;
|
|
1156
1250
|
this.#appearance = mode;
|
|
1251
|
+
for (const cb of [...this.#appearanceReportCallbacks]) {
|
|
1252
|
+
try {
|
|
1253
|
+
cb(mode);
|
|
1254
|
+
} catch {
|
|
1255
|
+
/* ignore callback errors */
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!changed) return;
|
|
1157
1259
|
for (const cb of this.#appearanceCallbacks) {
|
|
1158
1260
|
try {
|
|
1159
1261
|
cb(mode);
|
|
@@ -1359,6 +1461,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
1359
1461
|
}
|
|
1360
1462
|
|
|
1361
1463
|
stop(): void {
|
|
1464
|
+
// Suppress observer/timer callbacks before any teardown can yield or throw.
|
|
1465
|
+
this.#active = false;
|
|
1362
1466
|
if (this.#headless) return;
|
|
1363
1467
|
// Unregister from emergency cleanup
|
|
1364
1468
|
if (activeTerminal === this) {
|
|
@@ -1466,6 +1570,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
1466
1570
|
process.stdout.removeListener("resize", this.#stdoutResizeListener);
|
|
1467
1571
|
this.#stdoutResizeListener = undefined;
|
|
1468
1572
|
}
|
|
1573
|
+
if (this.#stdoutDrainArmed) {
|
|
1574
|
+
process.stdout.removeListener("drain", this.#stdoutDrainHandler);
|
|
1575
|
+
this.#stdoutDrainArmed = false;
|
|
1576
|
+
}
|
|
1577
|
+
this.#stdoutBacklog.reset();
|
|
1469
1578
|
this.#resizeHandler = undefined;
|
|
1470
1579
|
|
|
1471
1580
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
@@ -1512,7 +1621,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1512
1621
|
}
|
|
1513
1622
|
|
|
1514
1623
|
if (process.platform === "win32") {
|
|
1515
|
-
void postmortem.quit(129);
|
|
1624
|
+
void postmortem.quit(129, { drainStdout: false });
|
|
1516
1625
|
return;
|
|
1517
1626
|
}
|
|
1518
1627
|
try {
|
|
@@ -1559,13 +1668,26 @@ export class ProcessTerminal implements Terminal {
|
|
|
1559
1668
|
// `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
|
|
1560
1669
|
// and a code-unit cap would let CJK transcript rows expand past the
|
|
1561
1670
|
// threshold. See #2034 and #2095.
|
|
1562
|
-
|
|
1671
|
+
const bytes = Buffer.byteLength(data, "utf8");
|
|
1672
|
+
let accepted: boolean;
|
|
1673
|
+
if (isConPTYHosted() && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1674
|
+
accepted = true;
|
|
1563
1675
|
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1564
1676
|
if (this.#dead) break;
|
|
1565
|
-
process.stdout.write(chunk);
|
|
1677
|
+
accepted = process.stdout.write(chunk);
|
|
1566
1678
|
}
|
|
1567
1679
|
} else {
|
|
1568
|
-
process.stdout.write(data);
|
|
1680
|
+
accepted = process.stdout.write(data);
|
|
1681
|
+
}
|
|
1682
|
+
// A stalled-but-alive PTY consumer never throws: write() just returns
|
|
1683
|
+
// false and queues the bytes. Bound that never-draining backlog by
|
|
1684
|
+
// declaring the terminal disconnected once it crosses the cap — the
|
|
1685
|
+
// same clean-exit path a dead terminal takes (#6854).
|
|
1686
|
+
if (this.#stdoutBacklog.record(accepted, bytes)) {
|
|
1687
|
+
this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
|
|
1688
|
+
} else if (this.#stdoutBacklog.tracking && !this.#stdoutDrainArmed) {
|
|
1689
|
+
this.#stdoutDrainArmed = true;
|
|
1690
|
+
process.stdout.once("drain", this.#stdoutDrainHandler);
|
|
1569
1691
|
}
|
|
1570
1692
|
} catch (err) {
|
|
1571
1693
|
this.#markTerminalDisconnected("stdout failed", err);
|