@oh-my-pi/pi-tui 17.1.7 → 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 +20 -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 +38 -21
- package/src/terminal.ts +136 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
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
|
+
|
|
18
|
+
## [17.1.8] - 2026-07-28
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed wrapped Markdown list continuations losing their hanging indentation in narrow terminal layouts.
|
|
23
|
+
- Fixed an issue where emergency exits from fullscreen overlays could leave the Kitty keyboard protocol active, corrupting Arrow Up input in the terminal after exiting.
|
|
24
|
+
|
|
5
25
|
## [17.1.7] - 2026-07-27
|
|
6
26
|
|
|
7
27
|
### Added
|
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
|
/**
|
|
@@ -1874,9 +1875,10 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1874
1875
|
);
|
|
1875
1876
|
const tokenLineOffsets = [0];
|
|
1876
1877
|
for (const line of renderedTokenLines) {
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
|
|
1878
|
+
// Lists wrap while their structural prefixes are still available, so
|
|
1879
|
+
// continuation rows retain the correct hanging indent. Re-wrapping the
|
|
1880
|
+
// flattened rows here would discard that structure.
|
|
1881
|
+
if (token.type === "list" || TERMINAL.isImageLine(line) || isOsc66Line(line)) {
|
|
1880
1882
|
wrappedLines.push(line);
|
|
1881
1883
|
} else {
|
|
1882
1884
|
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
|
@@ -2273,7 +2275,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2273
2275
|
}
|
|
2274
2276
|
|
|
2275
2277
|
case "list": {
|
|
2276
|
-
const listLines = this.#renderList(token as ListToken, 0, styleContext);
|
|
2278
|
+
const listLines = this.#renderList(token as ListToken, 0, width, styleContext);
|
|
2277
2279
|
lines.push(...listLines);
|
|
2278
2280
|
// Don't add spacing after lists if a space token follows
|
|
2279
2281
|
// (the space token will handle it)
|
|
@@ -2589,46 +2591,60 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2589
2591
|
/**
|
|
2590
2592
|
* Render a list with proper nesting support
|
|
2591
2593
|
*/
|
|
2592
|
-
#renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
|
|
2594
|
+
#renderList(token: ListToken, depth: number, width: number, styleContext?: InlineStyleContext): string[] {
|
|
2593
2595
|
const lines: string[] = [];
|
|
2594
2596
|
const indent = " ".repeat(depth);
|
|
2595
2597
|
// Use the list's start property (defaults to 1 for ordered lists)
|
|
2596
2598
|
const startNumber = token.start ?? 1;
|
|
2599
|
+
const pushWrapped = (text: string, firstPrefix: string, continuationPrefix: string): void => {
|
|
2600
|
+
const prefixWidth = visibleWidth(firstPrefix);
|
|
2601
|
+
if (prefixWidth >= width) {
|
|
2602
|
+
lines.push(truncateToWidth(firstPrefix, width, Ellipsis.Omit));
|
|
2603
|
+
lines.push(...wrapTextWithAnsi(text, Math.max(1, width)));
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const bodyWidth = width - prefixWidth;
|
|
2607
|
+
const wrapped = wrapTextWithAnsi(text, bodyWidth);
|
|
2608
|
+
if (wrapped.length === 0) {
|
|
2609
|
+
lines.push(firstPrefix);
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
lines.push(firstPrefix + wrapped[0]);
|
|
2613
|
+
for (let lineIndex = 1; lineIndex < wrapped.length; lineIndex++) {
|
|
2614
|
+
lines.push(continuationPrefix + wrapped[lineIndex]);
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2597
2617
|
|
|
2598
2618
|
for (let i = 0; i < token.items.length; i++) {
|
|
2599
2619
|
const item = token.items[i];
|
|
2600
2620
|
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
|
|
2621
|
+
const firstPrefix = indent + this.#theme.listBullet(bullet);
|
|
2601
2622
|
// Continuation rows align under the item text, so the hang matches the
|
|
2602
2623
|
// actual bullet width (`10. ` is 4 cells, not 2).
|
|
2603
|
-
const continuationIndent = indent + padding(bullet
|
|
2624
|
+
const continuationIndent = indent + padding(visibleWidth(bullet));
|
|
2604
2625
|
|
|
2605
2626
|
// Process item tokens; nested-list lines arrive structurally tagged and
|
|
2606
2627
|
// already carry their own full indent.
|
|
2607
|
-
const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
|
|
2628
|
+
const itemLines = this.#renderListItem(item.tokens || [], depth, width, styleContext);
|
|
2608
2629
|
|
|
2609
2630
|
if (itemLines.length > 0) {
|
|
2610
2631
|
const firstLine = itemLines[0]!;
|
|
2611
2632
|
if (firstLine.nested) {
|
|
2612
|
-
// Nested list first - keep as-is (already has full indent)
|
|
2613
2633
|
lines.push(firstLine.text);
|
|
2614
2634
|
} else {
|
|
2615
|
-
|
|
2616
|
-
lines.push(indent + this.#theme.listBullet(bullet) + firstLine.text);
|
|
2635
|
+
pushWrapped(firstLine.text, firstPrefix, continuationIndent);
|
|
2617
2636
|
}
|
|
2618
2637
|
|
|
2619
|
-
// Rest of the lines
|
|
2620
2638
|
for (let j = 1; j < itemLines.length; j++) {
|
|
2621
2639
|
const line = itemLines[j]!;
|
|
2622
2640
|
if (line.nested) {
|
|
2623
|
-
// Nested list line - already has full indent
|
|
2624
2641
|
lines.push(line.text);
|
|
2625
2642
|
} else {
|
|
2626
|
-
|
|
2627
|
-
lines.push(continuationIndent + line.text);
|
|
2643
|
+
pushWrapped(line.text, continuationIndent, continuationIndent);
|
|
2628
2644
|
}
|
|
2629
2645
|
}
|
|
2630
2646
|
} else {
|
|
2631
|
-
lines.push(
|
|
2647
|
+
lines.push(firstPrefix);
|
|
2632
2648
|
}
|
|
2633
2649
|
}
|
|
2634
2650
|
|
|
@@ -2644,6 +2660,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2644
2660
|
#renderListItem(
|
|
2645
2661
|
tokens: Token[],
|
|
2646
2662
|
parentDepth: number,
|
|
2663
|
+
width: number,
|
|
2647
2664
|
styleContext?: InlineStyleContext,
|
|
2648
2665
|
): Array<{ text: string; nested: boolean }> {
|
|
2649
2666
|
const lines: Array<{ text: string; nested: boolean }> = [];
|
|
@@ -2652,7 +2669,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2652
2669
|
if (token.type === "list") {
|
|
2653
2670
|
// Nested list - render with one additional indent level
|
|
2654
2671
|
// These lines carry their own indent, so tag them for pass-through
|
|
2655
|
-
const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
|
|
2672
|
+
const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, width, styleContext);
|
|
2656
2673
|
for (const nestedLine of nestedLines) {
|
|
2657
2674
|
lines.push({ text: nestedLine, nested: true });
|
|
2658
2675
|
}
|
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
|
*/
|
|
@@ -275,17 +335,15 @@ export function emergencyTerminalRestore(): void {
|
|
|
275
335
|
restoreTerminalStderr();
|
|
276
336
|
const terminal = activeTerminal;
|
|
277
337
|
if (terminal) {
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
// state and exits it on the normal shutdown path. Only crash paths
|
|
281
|
-
// with a fullscreen overlay still hold the alt buffer here. The
|
|
282
|
-
// leave sequence is gated on the tracked state because it is NOT a
|
|
283
|
-
// universally safe no-op: Windows' VT dispatcher homes the cursor
|
|
284
|
-
// on DECRST 1049 even when the alt buffer is inactive.
|
|
338
|
+
// Keyboard enhancement state is screen-local: pop the alt-screen
|
|
339
|
+
// frame before leaving it, then let stop() pop omp's main-screen frame.
|
|
285
340
|
if (altScreenActive) {
|
|
286
|
-
|
|
341
|
+
const keyboardExit =
|
|
342
|
+
terminal.keyboardEnhancementExitSequence ?? (terminal.kittyEnableSequence ? "\x1b[<u" : "");
|
|
343
|
+
terminal.write(`${keyboardExit}\x1b[?1049l`);
|
|
287
344
|
altScreenActive = false;
|
|
288
345
|
}
|
|
346
|
+
terminal.stop();
|
|
289
347
|
terminal.showCursor(true);
|
|
290
348
|
} else if (terminalEverStarted && !isTerminalHeadless()) {
|
|
291
349
|
// Blind restore only if we know a terminal was started but lost track of it
|
|
@@ -305,7 +363,7 @@ export function emergencyTerminalRestore(): void {
|
|
|
305
363
|
// actually holds it — on Windows, DECRST 1049 on the main
|
|
306
364
|
// buffer homes the cursor (unconditional CursorRestoreState
|
|
307
365
|
// with no prior save), corrupting the shell handoff on exit.
|
|
308
|
-
(altScreenActive ? "\x1b[?1049l" : "") +
|
|
366
|
+
(altScreenActive ? "\x1b[?1049l\x1b[?1l\x1b>\x1b[<u" : "") + // Leave alt; reset main keyboard
|
|
309
367
|
"\x1b[?25h", // Show cursor
|
|
310
368
|
);
|
|
311
369
|
altScreenActive = false;
|
|
@@ -387,6 +445,13 @@ export interface Terminal {
|
|
|
387
445
|
* already-detected appearance so late subscribers never miss it.
|
|
388
446
|
*/
|
|
389
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;
|
|
390
455
|
/**
|
|
391
456
|
* Issue a single OSC 11 background-color re-query, driving the appearance
|
|
392
457
|
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
@@ -480,6 +545,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
480
545
|
this.#markTerminalDisconnected("stdin failed", err);
|
|
481
546
|
};
|
|
482
547
|
#dead = false;
|
|
548
|
+
#active = false;
|
|
483
549
|
// Last cursor visibility written to the terminal, sniffed from every
|
|
484
550
|
// outgoing sequence (frame buffers embed their own ?25h/?25l), so
|
|
485
551
|
// hideCursor()/showCursor() can skip same-state writes. `undefined` =
|
|
@@ -495,10 +561,21 @@ export class ProcessTerminal implements Terminal {
|
|
|
495
561
|
#stdoutErrorHandler = (err: Error) => {
|
|
496
562
|
this.#markTerminalDisconnected("stdout failed", err);
|
|
497
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
|
+
};
|
|
498
574
|
|
|
499
575
|
#windowsVTInputRestore?: () => void;
|
|
500
576
|
#xtermScrollToBottomRestoreModes = new Set<number>();
|
|
501
577
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
578
|
+
#appearanceReportCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
502
579
|
#appearance: TerminalAppearance | undefined;
|
|
503
580
|
#osc11Pending = false;
|
|
504
581
|
#osc11QueuedRoute?: Osc11QueryRoute;
|
|
@@ -562,16 +639,28 @@ export class ProcessTerminal implements Terminal {
|
|
|
562
639
|
}
|
|
563
640
|
}
|
|
564
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
|
+
|
|
565
653
|
/**
|
|
566
654
|
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
567
655
|
* startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
|
|
568
656
|
* appearance callbacks. Inside tmux, only this explicit path wraps the query
|
|
569
657
|
* and sentinel together for passthrough to the outer terminal; startup and
|
|
570
658
|
* Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
|
|
571
|
-
* armed. Suppressed while headless or after the terminal is torn
|
|
659
|
+
* armed. Suppressed while inactive, headless, or after the terminal is torn
|
|
660
|
+
* down.
|
|
572
661
|
*/
|
|
573
662
|
refreshAppearance(): void {
|
|
574
|
-
if (this.#headless || this.#dead) return;
|
|
663
|
+
if (!this.#active || this.#headless || this.#dead) return;
|
|
575
664
|
this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct");
|
|
576
665
|
}
|
|
577
666
|
|
|
@@ -653,6 +742,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
653
742
|
// The query handler intercepts input temporarily, then installs the user's handler
|
|
654
743
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
655
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;
|
|
656
748
|
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
657
749
|
|
|
658
750
|
// Query terminal background color via OSC 11 for dark/light detection.
|
|
@@ -1154,8 +1246,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
1154
1246
|
};
|
|
1155
1247
|
const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
|
|
1156
1248
|
const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
|
|
1157
|
-
|
|
1249
|
+
const changed = mode !== this.#appearance;
|
|
1158
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;
|
|
1159
1259
|
for (const cb of this.#appearanceCallbacks) {
|
|
1160
1260
|
try {
|
|
1161
1261
|
cb(mode);
|
|
@@ -1361,6 +1461,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
1361
1461
|
}
|
|
1362
1462
|
|
|
1363
1463
|
stop(): void {
|
|
1464
|
+
// Suppress observer/timer callbacks before any teardown can yield or throw.
|
|
1465
|
+
this.#active = false;
|
|
1364
1466
|
if (this.#headless) return;
|
|
1365
1467
|
// Unregister from emergency cleanup
|
|
1366
1468
|
if (activeTerminal === this) {
|
|
@@ -1468,6 +1570,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
1468
1570
|
process.stdout.removeListener("resize", this.#stdoutResizeListener);
|
|
1469
1571
|
this.#stdoutResizeListener = undefined;
|
|
1470
1572
|
}
|
|
1573
|
+
if (this.#stdoutDrainArmed) {
|
|
1574
|
+
process.stdout.removeListener("drain", this.#stdoutDrainHandler);
|
|
1575
|
+
this.#stdoutDrainArmed = false;
|
|
1576
|
+
}
|
|
1577
|
+
this.#stdoutBacklog.reset();
|
|
1471
1578
|
this.#resizeHandler = undefined;
|
|
1472
1579
|
|
|
1473
1580
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
@@ -1514,7 +1621,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1514
1621
|
}
|
|
1515
1622
|
|
|
1516
1623
|
if (process.platform === "win32") {
|
|
1517
|
-
void postmortem.quit(129);
|
|
1624
|
+
void postmortem.quit(129, { drainStdout: false });
|
|
1518
1625
|
return;
|
|
1519
1626
|
}
|
|
1520
1627
|
try {
|
|
@@ -1561,13 +1668,26 @@ export class ProcessTerminal implements Terminal {
|
|
|
1561
1668
|
// `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
|
|
1562
1669
|
// and a code-unit cap would let CJK transcript rows expand past the
|
|
1563
1670
|
// threshold. See #2034 and #2095.
|
|
1564
|
-
|
|
1671
|
+
const bytes = Buffer.byteLength(data, "utf8");
|
|
1672
|
+
let accepted: boolean;
|
|
1673
|
+
if (isConPTYHosted() && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1674
|
+
accepted = true;
|
|
1565
1675
|
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1566
1676
|
if (this.#dead) break;
|
|
1567
|
-
process.stdout.write(chunk);
|
|
1677
|
+
accepted = process.stdout.write(chunk);
|
|
1568
1678
|
}
|
|
1569
1679
|
} else {
|
|
1570
|
-
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);
|
|
1571
1691
|
}
|
|
1572
1692
|
} catch (err) {
|
|
1573
1693
|
this.#markTerminalDisconnected("stdout failed", err);
|