@gajae-code/tui 0.10.2 → 0.11.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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.0] - 2026-07-15
6
+ ### Fixed
7
+
8
+ - Shared the temporary stdout error listener across terminal instances, preventing `MaxListenersExceededWarning` during repeated TUI start/stop cycles while retaining late detached-PTY error handling.
9
+ - Added a TUI-lifetime terminal cleanup queue so component-owned escape cleanup can be retried after terminal recovery even when the originating component has already been disposed.
10
+
11
+ ### Added
12
+
13
+ - Added opt-in disabled items to `SelectList` (`SelectItem.disabled`): disabled entries render dimmed; arrow navigation wraps while page navigation clamps and both skip disabled targets; filter resets choose the first enabled item; and programmatic selection searches forward from the requested index before falling back backward. Callbacks never receive disabled entries, while enabled-only arrow/page inputs preserve their existing notification behavior. All-disabled lists keep a null selection while an independent viewport remains navigable, with no cursor and a `(-/N)` scroll position.
14
+
5
15
  ## [0.10.2] - 2026-07-14
6
16
  ### Fixed
7
17
 
@@ -0,0 +1,35 @@
1
+ import { type Component, type Focusable } from "../tui";
2
+ declare const secretValueIssuer: unique symbol;
3
+ /**
4
+ * A one-shot secret transfer handle. The contained value is cleared immediately
5
+ * after it is consumed and cannot be read through any other public API.
6
+ */
7
+ export declare class SecretValue {
8
+ #private;
9
+ /** @internal SecretInput is the sole issuer of usable SecretValue handles. */
10
+ constructor(value: string, issuer: typeof secretValueIssuer);
11
+ consume(): string;
12
+ }
13
+ /**
14
+ * A single-line masked input for credentials and other write-only secrets.
15
+ *
16
+ * The editing behavior follows Input, but render output is derived only from
17
+ * grapheme counts; the backing characters are never returned or rendered.
18
+ */
19
+ export declare class SecretInput implements Component, Focusable {
20
+ #private;
21
+ readonly placeholder: string;
22
+ onSubmit?: (value: SecretValue) => void;
23
+ onEscape?: () => void;
24
+ /** Focusable interface - set by TUI when focus changes. */
25
+ focused: boolean;
26
+ constructor(options?: {
27
+ placeholder?: string;
28
+ });
29
+ handleInput(data: string): void;
30
+ clear(): void;
31
+ dispose(): void;
32
+ invalidate(): void;
33
+ render(width: number): string[];
34
+ }
35
+ export {};
@@ -8,6 +8,7 @@ export * from "./components/image";
8
8
  export * from "./components/input";
9
9
  export * from "./components/loader";
10
10
  export * from "./components/markdown";
11
+ export * from "./components/secret-input";
11
12
  export * from "./components/select-list";
12
13
  export * from "./components/settings-list";
13
14
  export * from "./components/spacer";
@@ -58,13 +58,8 @@ interface TerminalSizeStream {
58
58
  }
59
59
  export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envColumns?: string | undefined): number;
60
60
  export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
61
- /**
62
- * Test-only: reset the shared stdout-error dispatcher to a clean slate.
63
- * Used by tests to avoid cross-test leakage of the module-level subscriber set
64
- * (a leaked subscriber otherwise keeps `size > 0`, so a later subscribe no longer
65
- * re-arms the process.stdout listener). Not part of the public runtime contract.
66
- */
67
- export declare function __resetStdoutErrorHandlingForTest(): void;
61
+ export declare function __stdoutErrorSubscriberCountForTests(): number;
62
+ export declare function __stdoutErrorDispatcherInstalledForTests(): boolean;
68
63
  /**
69
64
  * Real terminal using process.stdin/stdout
70
65
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.10.2",
4
+ "version": "0.11.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -35,8 +35,8 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@gajae-code/natives": "0.10.2",
39
- "@gajae-code/utils": "0.10.2",
38
+ "@gajae-code/natives": "0.11.0",
39
+ "@gajae-code/utils": "0.11.0",
40
40
  "lru-cache": "11.3.6",
41
41
  "marked": "^18.0.3"
42
42
  },
@@ -0,0 +1,485 @@
1
+ import { BracketedPasteHandler } from "../bracketed-paste";
2
+ import { getKeybindings } from "../keybindings";
3
+ import { extractPrintableText } from "../keys";
4
+ import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
5
+ import {
6
+ getSegmenter,
7
+ getWordNavKind,
8
+ moveWordLeft,
9
+ moveWordRight,
10
+ padding,
11
+ replaceTabs,
12
+ sliceWithWidth,
13
+ visibleWidth,
14
+ } from "../utils";
15
+
16
+ const segmenter = getSegmenter();
17
+ const secretValueIssuer = Symbol("SecretValue issuer");
18
+
19
+ interface SecretInputState {
20
+ value: string;
21
+ cursor: number;
22
+ }
23
+
24
+ function insertTextNfcAt(value: string, cursor: number, text: string): { value: string; cursor: number } {
25
+ const before = value.slice(0, cursor);
26
+ const after = value.slice(cursor);
27
+ const beforeWithInsert = (before + text).normalize("NFC");
28
+ return {
29
+ value: (beforeWithInsert + after).normalize("NFC"),
30
+ cursor: beforeWithInsert.length,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * A one-shot secret transfer handle. The contained value is cleared immediately
36
+ * after it is consumed and cannot be read through any other public API.
37
+ */
38
+ export class SecretValue {
39
+ #value: string;
40
+ #consumed = false;
41
+
42
+ /** @internal SecretInput is the sole issuer of usable SecretValue handles. */
43
+ constructor(value: string, issuer: typeof secretValueIssuer) {
44
+ if (issuer !== secretValueIssuer) {
45
+ throw new TypeError("SecretValue handles can only be created by SecretInput");
46
+ }
47
+ this.#value = value;
48
+ }
49
+
50
+ consume(): string {
51
+ if (this.#consumed) {
52
+ return "";
53
+ }
54
+
55
+ this.#consumed = true;
56
+ const value = this.#value;
57
+ this.#value = "";
58
+ return value;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * A single-line masked input for credentials and other write-only secrets.
64
+ *
65
+ * The editing behavior follows Input, but render output is derived only from
66
+ * grapheme counts; the backing characters are never returned or rendered.
67
+ */
68
+ export class SecretInput implements Component, Focusable {
69
+ #value = "";
70
+ #cursor = 0;
71
+ #pasteHandler = new BracketedPasteHandler();
72
+ #killRing: string[] = [];
73
+ #lastAction: "kill" | "yank" | "type-word" | null = null;
74
+ #undoStack: SecretInputState[] = [];
75
+ #disposed = false;
76
+
77
+ readonly placeholder: string;
78
+ onSubmit?: (value: SecretValue) => void;
79
+ onEscape?: () => void;
80
+
81
+ /** Focusable interface - set by TUI when focus changes. */
82
+ focused = false;
83
+
84
+ constructor(options: { placeholder?: string } = {}) {
85
+ this.placeholder = options.placeholder ?? "";
86
+ }
87
+
88
+ handleInput(data: string): void {
89
+ if (this.#disposed) {
90
+ return;
91
+ }
92
+
93
+ const paste = this.#pasteHandler.process(data);
94
+ if (paste.handled) {
95
+ if (paste.pasteContent !== undefined) {
96
+ this.#handlePaste(paste.pasteContent);
97
+ if (paste.remaining.length > 0) {
98
+ this.handleInput(paste.remaining);
99
+ }
100
+ }
101
+ return;
102
+ }
103
+
104
+ const kb = getKeybindings();
105
+
106
+ if (kb.matches(data, "tui.select.cancel")) {
107
+ this.clear();
108
+ this.onEscape?.();
109
+ return;
110
+ }
111
+
112
+ if (kb.matches(data, "tui.editor.undo")) {
113
+ this.#undo();
114
+ return;
115
+ }
116
+
117
+ if (kb.matches(data, "tui.input.submit") || data === "\n") {
118
+ this.#submit();
119
+ return;
120
+ }
121
+
122
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
123
+ this.#handleBackspace();
124
+ return;
125
+ }
126
+
127
+ if (kb.matches(data, "tui.editor.deleteCharForward")) {
128
+ this.#handleForwardDelete();
129
+ return;
130
+ }
131
+
132
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
133
+ this.#deleteWordBackwards();
134
+ return;
135
+ }
136
+
137
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
138
+ this.#deleteWordForward();
139
+ return;
140
+ }
141
+
142
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
143
+ this.#deleteToLineStart();
144
+ return;
145
+ }
146
+
147
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
148
+ this.#deleteToLineEnd();
149
+ return;
150
+ }
151
+
152
+ if (kb.matches(data, "tui.editor.yank")) {
153
+ this.#yank();
154
+ return;
155
+ }
156
+
157
+ if (kb.matches(data, "tui.editor.yankPop")) {
158
+ this.#yankPop();
159
+ return;
160
+ }
161
+
162
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
163
+ this.#lastAction = null;
164
+ if (this.#cursor > 0) {
165
+ const lastGrapheme = [...segmenter.segment(this.#value.slice(0, this.#cursor))].at(-1);
166
+ this.#cursor -= lastGrapheme?.segment.length ?? 1;
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (kb.matches(data, "tui.editor.cursorRight")) {
172
+ this.#lastAction = null;
173
+ if (this.#cursor < this.#value.length) {
174
+ const [firstGrapheme] = segmenter.segment(this.#value.slice(this.#cursor));
175
+ this.#cursor += firstGrapheme?.segment.length ?? 1;
176
+ }
177
+ return;
178
+ }
179
+
180
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
181
+ this.#lastAction = null;
182
+ this.#cursor = 0;
183
+ return;
184
+ }
185
+
186
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
187
+ this.#lastAction = null;
188
+ this.#cursor = this.#value.length;
189
+ return;
190
+ }
191
+
192
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
193
+ this.#moveWordBackwards();
194
+ return;
195
+ }
196
+
197
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
198
+ this.#moveWordForwards();
199
+ return;
200
+ }
201
+
202
+ const printableText = extractPrintableText(data);
203
+ if (printableText) {
204
+ this.#insertCharacter(printableText);
205
+ }
206
+ }
207
+
208
+ clear(): void {
209
+ this.#value = "";
210
+ this.#cursor = 0;
211
+ this.#lastAction = null;
212
+ for (const snapshot of this.#undoStack) {
213
+ snapshot.value = "";
214
+ snapshot.cursor = 0;
215
+ }
216
+ this.#undoStack.length = 0;
217
+ this.#killRing.fill("");
218
+ this.#killRing.length = 0;
219
+ // BracketedPasteHandler intentionally keeps its buffer private. Replacing it
220
+ // drops any in-progress secret paste without retaining it in this component.
221
+ this.#pasteHandler = new BracketedPasteHandler();
222
+ }
223
+
224
+ dispose(): void {
225
+ if (this.#disposed) {
226
+ return;
227
+ }
228
+
229
+ this.clear();
230
+ this.focused = false;
231
+ this.onSubmit = undefined;
232
+ this.onEscape = undefined;
233
+ this.#disposed = true;
234
+ }
235
+
236
+ invalidate(): void {
237
+ // No cached state to invalidate currently.
238
+ }
239
+
240
+ render(width: number): string[] {
241
+ if (this.#disposed) {
242
+ return [];
243
+ }
244
+
245
+ const prompt = "> ";
246
+ const availableWidth = width - prompt.length;
247
+ if (availableWidth <= 0) {
248
+ return [prompt];
249
+ }
250
+
251
+ const masked = this.#maskedValueAndCursor();
252
+ const displayValue = masked.value.length === 0 && this.placeholder.length > 0 ? this.placeholder : masked.value;
253
+ const cursorIndex = masked.value.length === 0 && this.placeholder.length > 0 ? 0 : masked.cursor;
254
+ const cursorDisplayValue = cursorIndex >= displayValue.length ? `${displayValue} ` : displayValue;
255
+ const totalCols = visibleWidth(cursorDisplayValue);
256
+ const cursorCols = visibleWidth(cursorDisplayValue.slice(0, cursorIndex));
257
+ const cursorIterator = segmenter.segment(cursorDisplayValue.slice(cursorIndex))[Symbol.iterator]();
258
+ const cursorGrapheme = cursorIterator.next().value?.segment ?? " ";
259
+ const cursorGraphemeWidth = visibleWidth(cursorGrapheme);
260
+
261
+ const maxStart = Math.max(0, totalCols - availableWidth);
262
+ let startCol = 0;
263
+ if (totalCols > availableWidth) {
264
+ const half = Math.floor(availableWidth / 2);
265
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - half));
266
+ const maxCursorRel = Math.max(0, availableWidth - cursorGraphemeWidth);
267
+ if (cursorCols - startCol > maxCursorRel) {
268
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - maxCursorRel));
269
+ }
270
+ }
271
+
272
+ const visibleText = sliceWithWidth(cursorDisplayValue, startCol, availableWidth, true).text;
273
+ const prefixText = sliceWithWidth(cursorDisplayValue, startCol, Math.max(0, cursorCols - startCol), true).text;
274
+ const cursorDisplay = Math.max(0, Math.min(prefixText.length, visibleText.length));
275
+ const [cursorSegment] = segmenter.segment(visibleText.slice(cursorDisplay));
276
+ const atCursor = cursorSegment?.segment ?? " ";
277
+ const beforeCursor = visibleText.slice(0, cursorDisplay);
278
+ const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
279
+ const marker = this.focused ? CURSOR_MARKER : "";
280
+ const cursorChar = `\x1b[7m${atCursor}\x1b[27m`;
281
+ const remainingAfterWidth = Math.max(0, availableWidth - visibleWidth(beforeCursor) - visibleWidth(atCursor));
282
+ const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
283
+ const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
284
+ const line = prompt + beforeCursor + marker + cursorChar + clampedAfterCursor;
285
+ return [line + padding(Math.max(0, availableWidth - visibleWidth(renderedNoMarker)))];
286
+ }
287
+
288
+ #submit(): void {
289
+ const secret = new SecretValue(this.#value, secretValueIssuer);
290
+ this.clear();
291
+ this.onSubmit?.(secret);
292
+ }
293
+
294
+ #insertCharacter(text: string): void {
295
+ const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
296
+ if (!isWordChunk || this.#lastAction !== "type-word") {
297
+ this.#pushUndo();
298
+ }
299
+ this.#lastAction = "type-word";
300
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
301
+ this.#value = inserted.value;
302
+ this.#cursor = inserted.cursor;
303
+ }
304
+
305
+ #handleBackspace(): void {
306
+ this.#lastAction = null;
307
+ if (this.#cursor <= 0) {
308
+ return;
309
+ }
310
+
311
+ this.#pushUndo();
312
+ const lastGrapheme = [...segmenter.segment(this.#value.slice(0, this.#cursor))].at(-1);
313
+ const graphemeLength = lastGrapheme?.segment.length ?? 1;
314
+ this.#value = this.#value.slice(0, this.#cursor - graphemeLength) + this.#value.slice(this.#cursor);
315
+ this.#cursor -= graphemeLength;
316
+ }
317
+
318
+ #handleForwardDelete(): void {
319
+ this.#lastAction = null;
320
+ if (this.#cursor >= this.#value.length) {
321
+ return;
322
+ }
323
+
324
+ this.#pushUndo();
325
+ const [firstGrapheme] = segmenter.segment(this.#value.slice(this.#cursor));
326
+ const graphemeLength = firstGrapheme?.segment.length ?? 1;
327
+ this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(this.#cursor + graphemeLength);
328
+ }
329
+
330
+ #deleteToLineStart(): void {
331
+ if (this.#cursor === 0) {
332
+ return;
333
+ }
334
+
335
+ this.#pushUndo();
336
+ this.#pushKill(this.#value.slice(0, this.#cursor), true, this.#lastAction === "kill");
337
+ this.#lastAction = "kill";
338
+ this.#value = this.#value.slice(this.#cursor);
339
+ this.#cursor = 0;
340
+ }
341
+
342
+ #deleteToLineEnd(): void {
343
+ if (this.#cursor >= this.#value.length) {
344
+ return;
345
+ }
346
+
347
+ this.#pushUndo();
348
+ this.#pushKill(this.#value.slice(this.#cursor), false, this.#lastAction === "kill");
349
+ this.#lastAction = "kill";
350
+ this.#value = this.#value.slice(0, this.#cursor);
351
+ }
352
+
353
+ #deleteWordBackwards(): void {
354
+ if (this.#cursor === 0) {
355
+ return;
356
+ }
357
+
358
+ const wasKill = this.#lastAction === "kill";
359
+ this.#pushUndo();
360
+ const oldCursor = this.#cursor;
361
+ this.#moveWordBackwards();
362
+ const deleteFrom = this.#cursor;
363
+ this.#cursor = oldCursor;
364
+ this.#pushKill(this.#value.slice(deleteFrom, this.#cursor), true, wasKill);
365
+ this.#lastAction = "kill";
366
+ this.#value = this.#value.slice(0, deleteFrom) + this.#value.slice(this.#cursor);
367
+ this.#cursor = deleteFrom;
368
+ }
369
+
370
+ #deleteWordForward(): void {
371
+ if (this.#cursor >= this.#value.length) {
372
+ return;
373
+ }
374
+
375
+ const wasKill = this.#lastAction === "kill";
376
+ this.#pushUndo();
377
+ const oldCursor = this.#cursor;
378
+ this.#moveWordForwards();
379
+ const deleteTo = this.#cursor;
380
+ this.#cursor = oldCursor;
381
+ this.#pushKill(this.#value.slice(this.#cursor, deleteTo), false, wasKill);
382
+ this.#lastAction = "kill";
383
+ this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(deleteTo);
384
+ }
385
+
386
+ #yank(): void {
387
+ const text = this.#killRing.at(-1);
388
+ if (!text) {
389
+ return;
390
+ }
391
+
392
+ this.#pushUndo();
393
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
394
+ this.#value = inserted.value;
395
+ this.#cursor = inserted.cursor;
396
+ this.#lastAction = "yank";
397
+ }
398
+
399
+ #yankPop(): void {
400
+ if (this.#lastAction !== "yank" || this.#killRing.length <= 1) {
401
+ return;
402
+ }
403
+
404
+ this.#pushUndo();
405
+ const previous = this.#killRing.at(-1) ?? "";
406
+ this.#value = this.#value.slice(0, this.#cursor - previous.length) + this.#value.slice(this.#cursor);
407
+ this.#cursor -= previous.length;
408
+ const last = this.#killRing.pop();
409
+ if (last !== undefined) {
410
+ this.#killRing.unshift(last);
411
+ }
412
+ const text = this.#killRing.at(-1) ?? "";
413
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
414
+ this.#value = inserted.value;
415
+ this.#cursor = inserted.cursor;
416
+ this.#lastAction = "yank";
417
+ }
418
+
419
+ #pushUndo(): void {
420
+ this.#undoStack.push({ value: this.#value, cursor: this.#cursor });
421
+ }
422
+
423
+ #undo(): void {
424
+ const snapshot = this.#undoStack.pop();
425
+ if (!snapshot) {
426
+ return;
427
+ }
428
+
429
+ this.#value = snapshot.value;
430
+ this.#cursor = snapshot.cursor;
431
+ this.#lastAction = null;
432
+ }
433
+
434
+ #moveWordBackwards(): void {
435
+ if (this.#cursor === 0) {
436
+ return;
437
+ }
438
+ this.#lastAction = null;
439
+ this.#cursor = moveWordLeft(this.#value, this.#cursor);
440
+ }
441
+
442
+ #moveWordForwards(): void {
443
+ if (this.#cursor >= this.#value.length) {
444
+ return;
445
+ }
446
+ this.#lastAction = null;
447
+ this.#cursor = moveWordRight(this.#value, this.#cursor);
448
+ }
449
+
450
+ #handlePaste(pastedText: string): void {
451
+ this.#lastAction = null;
452
+ this.#pushUndo();
453
+ const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")).normalize(
454
+ "NFC",
455
+ );
456
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, cleanText);
457
+ this.#value = inserted.value;
458
+ this.#cursor = inserted.cursor;
459
+ }
460
+
461
+ #pushKill(text: string, prepend: boolean, accumulate: boolean): void {
462
+ if (!text) {
463
+ return;
464
+ }
465
+
466
+ if (accumulate && this.#killRing.length > 0) {
467
+ const lastIndex = this.#killRing.length - 1;
468
+ const last = this.#killRing[lastIndex];
469
+ this.#killRing[lastIndex] = prepend ? text + last : last + text;
470
+ return;
471
+ }
472
+
473
+ this.#killRing.push(text);
474
+ }
475
+
476
+ #maskedValueAndCursor(): { value: string; cursor: number } {
477
+ const before = this.#value.slice(0, this.#cursor);
478
+ const after = this.#value.slice(this.#cursor);
479
+ const beforeMask = "•".repeat([...segmenter.segment(before)].length);
480
+ return {
481
+ value: beforeMask + "•".repeat([...segmenter.segment(after)].length),
482
+ cursor: beforeMask.length,
483
+ };
484
+ }
485
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./components/image";
12
12
  export * from "./components/input";
13
13
  export * from "./components/loader";
14
14
  export * from "./components/markdown";
15
+ export * from "./components/secret-input";
15
16
  export * from "./components/select-list";
16
17
  export * from "./components/settings-list";
17
18
  export * from "./components/spacer";
package/src/terminal.ts CHANGED
@@ -172,6 +172,12 @@ function isWindowsSubsystemForLinux(): boolean {
172
172
  }
173
173
  const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
174
174
  const stdoutErrorSubscribers = new Set<(err: Error) => void>();
175
+ export function __stdoutErrorSubscriberCountForTests(): number {
176
+ return stdoutErrorSubscribers.size;
177
+ }
178
+ export function __stdoutErrorDispatcherInstalledForTests(): boolean {
179
+ return process.stdout.listeners("error").includes(dispatchStdoutError);
180
+ }
175
181
  const dispatchStdoutError = (err: Error): void => {
176
182
  for (const subscriber of stdoutErrorSubscribers) subscriber(err);
177
183
  };
@@ -186,17 +192,6 @@ function unsubscribeFromStdoutErrors(subscriber: (err: Error) => void): void {
186
192
  if (stdoutErrorSubscribers.size === 0) process.stdout.removeListener("error", dispatchStdoutError);
187
193
  }
188
194
 
189
- /**
190
- * Test-only: reset the shared stdout-error dispatcher to a clean slate.
191
- * Used by tests to avoid cross-test leakage of the module-level subscriber set
192
- * (a leaked subscriber otherwise keeps `size > 0`, so a later subscribe no longer
193
- * re-arms the process.stdout listener). Not part of the public runtime contract.
194
- */
195
- export function __resetStdoutErrorHandlingForTest(): void {
196
- stdoutErrorSubscribers.clear();
197
- process.stdout.removeListener("error", dispatchStdoutError);
198
- }
199
-
200
195
  /**
201
196
  * Real terminal using process.stdin/stdout
202
197
  */