@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 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
@@ -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 down.
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.1.7",
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.1.7",
41
- "@oh-my-pi/pi-utils": "17.1.7",
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
  },
@@ -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(frame)) {
66
- const remainder = content.slice(frame.length);
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.#updateDisplay();
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.#updateDisplay();
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.#updateDisplay();
134
+ this.#syncText();
135
+ this.#requestPaint();
116
136
  }
117
137
 
118
- #updateDisplay() {
119
- const frame = this.#frames[this.#currentFrame];
120
- const textChanged = this.setText(`${frame} ${this.message}`);
121
- if ((textChanged || this.messageColorFn.animated === true) && this.#ui) {
122
- // Direct write: a loader tick changes only this component, so the TUI
123
- // can update the already-positioned rows without driving the full
124
- // compose/prepare/diff pipeline. Lightweight test stubs may not carry
125
- // the newer API; keep their legacy component-scoped path working.
126
- if (typeof this.#ui.requestDirectWrite === "function") {
127
- this.#ui.requestDirectWrite(this);
128
- } else {
129
- this.#ui.requestComponentRender(this);
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 &#9731; and paths like foo#fff) and not trailed by more hex
1288
- // (so over-long runs never produce a misleading swatch). Length/letter rules
1289
- // are enforced in classifyHexColor since the alternation can't express "exactly
1290
- // 3, 6, or 8".
1291
- const HEX_COLOR_REGEX = /(?<![\w#&])#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
1287
+ // entities like &#9731; 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
- // Skip wrapping for image protocol lines and OSC 66 sized headings
1878
- // (would corrupt escape sequences / split the indivisible sized span).
1879
- if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
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.length);
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
- // Regular text content - add indent and bullet
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
- // Regular content - hang under the item text
2627
- lines.push(continuationIndent + line.text);
2643
+ pushWrapped(line.text, continuationIndent, continuationIndent);
2628
2644
  }
2629
2645
  }
2630
2646
  } else {
2631
- lines.push(indent + this.#theme.listBullet(bullet));
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
- terminal.stop();
279
- // stop() never touches the alternate screen the TUI owns that
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
- terminal.write("\x1b[?1049l");
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 down.
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
- if (mode === this.#appearance) return;
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
- if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
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);