@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 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
@@ -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.8",
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.8",
41
- "@oh-my-pi/pi-utils": "17.1.8",
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
  /**
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 down.
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
- if (mode === this.#appearance) return;
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
- 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;
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);