@gajae-code/tui 0.10.1 → 0.10.2

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,9 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.2] - 2026-07-14
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.1] - 2026-07-13
6
16
  ### Fixed
7
17
 
18
+ - Real interactive terminals now repaint only the visible viewport during forced renders instead of clearing and replaying native scrollback.
8
19
  - Terminal graphics protocols are no longer assumed under terminal multiplexers: the blind Kitty fallback for `TERM=tmux-*`/`screen-*` (and detected kitty/iTerm2 protocols leaking through multiplexer env) emitted raw graphics escapes the multiplexer consumed, leaving the Gajae composer pet invisible while its out-of-band cursor writes intermittently corrupted the TUI frame. Image protocols are now unconditionally dropped under tmux/screen/zellij (shared multiplexer predicate with the renderer host policy, including `$TMUX_PANE`, `$STY`, `$ZELLIJ`, and `GJC_TMUX_LAUNCHED`) unless `PI_FORCE_IMAGE_PROTOCOL` explicitly forces a protocol, which remains an expert override.
9
20
  - Fixed the startup sixel capability probe's response parsing and authority: XTSMGRAPHICS replies are read per spec (`Ps=0` success; `1/2/3` errors — tmux's `CSI ?2;3;0S` error no longer counts as support), the DA1 device-class parameter is no longer misread as the sixel extension attribute (`CSI ?4;6c` identifies a VT132, not sixel), an explicit `PI_FORCE_IMAGE_PROTOCOL` (including `off`) suppresses probing entirely, and the probe never runs inside a multiplexer because tmux advertises DA1 `;4` from compile-time support regardless of the attached client.
10
21
  - A configured Gajae pet now re-applies automatically when the asynchronous sixel probe enables graphics after startup (new `onImageProtocolChanged` subscription), instead of staying hidden until `/pet` is re-run; `/pet` also reports multiplexer graphics suppression explicitly instead of suggesting a different terminal.
package/README.md CHANGED
@@ -328,6 +328,8 @@ interface SelectItem {
328
328
  value: string;
329
329
  label: string;
330
330
  description?: string;
331
+ hint?: string; // Autocomplete hint consumed by Editor; SelectList does not render it
332
+ disabled?: boolean; // Dimmed, unselectable entry (see "Disabled items")
331
333
  }
332
334
 
333
335
  interface SelectListTheme {
@@ -352,14 +354,29 @@ list.onSelect = (item) => console.log("Selected:", item);
352
354
  list.onCancel = () => console.log("Cancelled");
353
355
  list.onSelectionChange = (item) => console.log("Highlighted:", item);
354
356
  list.setFilter("opt"); // Filter items
357
+ list.setSelectedIndex(1); // Select first enabled item at/after index 1, then search backward
355
358
  ```
356
359
 
357
360
  **Controls:**
358
361
 
359
- - Arrow keys: Navigate
362
+ - Arrow keys: Navigate and wrap at list edges
363
+ - PageUp/PageDown: Move by a visible page and clamp at list boundaries
360
364
  - Enter: Select
361
365
  - Escape: Cancel
362
366
 
367
+ **Disabled items:**
368
+
369
+ Items with `disabled: true` stay visible but can never be selected:
370
+
371
+ - They render dimmed (via `theme.description`) and never show the selection cursor.
372
+ - Arrow keys wrap while skipping disabled entries; PageUp/PageDown skip disabled targets and clamp at list boundaries.
373
+ - Filtering (`setFilter`) resets the selection to the first *enabled* item.
374
+ - `setSelectedIndex(i)` selects the first enabled item at or after the clamped index, falling back backward.
375
+ - `onSelect` and `onSelectionChange` never receive a disabled item.
376
+ - When every visible item is disabled, `getSelectedItem()` returns `null` and no
377
+ row shows a cursor. Arrow keys wrap the viewport, PageUp/PageDown clamp it,
378
+ and the scroll indicator reports `(-/N)` without claiming a selected row.
379
+
363
380
  ### SettingsList
364
381
 
365
382
  Settings panel with value cycling and submenus.
@@ -4,8 +4,14 @@ export interface SelectItem {
4
4
  value: string;
5
5
  label: string;
6
6
  description?: string;
7
- /** Dim hint text shown inline after cursor when this item is selected */
7
+ /** Autocomplete hint consumed by Editor; SelectList does not render it. */
8
8
  hint?: string;
9
+ /**
10
+ * Renders dimmed and can never be selected: navigation skips it, selection
11
+ * callbacks never fire for it, and a list whose visible items are all
12
+ * disabled reports no selection (`getSelectedItem()` returns `null`).
13
+ */
14
+ disabled?: boolean;
9
15
  }
10
16
  export interface SelectListTheme {
11
17
  selectedPrefix: (text: string) => string;
@@ -58,6 +58,13 @@ 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
68
  /**
62
69
  * Real terminal using process.stdin/stdout
63
70
  */
@@ -112,12 +112,14 @@ export type SizeValue = number | `${number}%`;
112
112
  export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
113
113
  /**
114
114
  * True when repainting only the live viewport is safer than clearing/replaying
115
- * the full transcript. Native Windows console hosts are included even when
116
- * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
117
- * identity variables while keeping the same scroll-jump behavior.
115
+ * the full transcript. Real process terminals are viewport-sensitive because
116
+ * their native scrollback position is not observable by the renderer. Native
117
+ * Windows console hosts are also recognized from platform identity when that
118
+ * process-terminal capability is unavailable.
118
119
  */
119
120
  export declare function shouldUseViewportRepaintForHost(env?: Record<string, string | undefined>, platform?: NodeJS.Platform, options?: {
120
121
  includeNativeWindows?: boolean;
122
+ includeProcessTerminal?: boolean;
121
123
  }): boolean;
122
124
  /**
123
125
  * Options for overlay positioning and sizing.
@@ -259,6 +261,10 @@ export declare class TUI extends Container {
259
261
  normalizationLimit: number;
260
262
  truncationLimit: number;
261
263
  };
264
+ /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
265
+ queueTerminalCleanup(payload: string, onDelivered?: () => void): void;
266
+ /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
267
+ flushTerminalCleanup(): void;
262
268
  /**
263
269
  * Register an emitter whose escape payload is appended to every render
264
270
  * write (inside its own synchronized-output block, cursor saved/restored).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.10.1",
4
+ "version": "0.10.2",
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.1",
39
- "@gajae-code/utils": "0.10.1",
38
+ "@gajae-code/natives": "0.10.2",
39
+ "@gajae-code/utils": "0.10.2",
40
40
  "lru-cache": "11.3.6",
41
41
  "marked": "^18.0.3"
42
42
  },
@@ -20,8 +20,14 @@ export interface SelectItem {
20
20
  value: string;
21
21
  label: string;
22
22
  description?: string;
23
- /** Dim hint text shown inline after cursor when this item is selected */
23
+ /** Autocomplete hint consumed by Editor; SelectList does not render it. */
24
24
  hint?: string;
25
+ /**
26
+ * Renders dimmed and can never be selected: navigation skips it, selection
27
+ * callbacks never fire for it, and a list whose visible items are all
28
+ * disabled reports no selection (`getSelectedItem()` returns `null`).
29
+ */
30
+ disabled?: boolean;
25
31
  }
26
32
 
27
33
  export interface SelectListTheme {
@@ -49,7 +55,10 @@ export interface SelectListLayoutOptions {
49
55
 
50
56
  export class SelectList implements Component {
51
57
  #filteredItems: ReadonlyArray<SelectItem>;
58
+ /** Index of the selected enabled item, or `-1` when no enabled item exists. */
52
59
  #selectedIndex: number = 0;
60
+ /** First rendered item while selection is absent. */
61
+ #viewportStartIndex: number = 0;
53
62
 
54
63
  onSelect?: (item: SelectItem) => void;
55
64
  onCancel?: () => void;
@@ -62,16 +71,26 @@ export class SelectList implements Component {
62
71
  private readonly layout: SelectListLayoutOptions = {},
63
72
  ) {
64
73
  this.#filteredItems = items;
74
+ this.#selectedIndex = this.#firstEnabledIndex();
75
+ this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
65
76
  }
66
77
 
67
78
  setFilter(filter: string): void {
68
79
  this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
69
- // Reset selection when filter changes
70
- this.#selectedIndex = 0;
80
+ this.#selectedIndex = this.#firstEnabledIndex();
81
+ this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
71
82
  }
72
83
 
73
84
  setSelectedIndex(index: number): void {
74
- this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
85
+ if (this.#filteredItems.length === 0) {
86
+ this.#selectedIndex = -1;
87
+ this.#viewportStartIndex = 0;
88
+ return;
89
+ }
90
+ const clamped = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
91
+ this.#selectedIndex =
92
+ this.#findEnabledIndex(clamped, 1, false) ?? this.#findEnabledIndex(clamped, -1, false) ?? -1;
93
+ this.#syncViewportToIndex(this.#selectedIndex >= 0 ? this.#selectedIndex : clamped);
75
94
  }
76
95
 
77
96
  invalidate(): void {
@@ -89,11 +108,10 @@ export class SelectList implements Component {
89
108
 
90
109
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
91
110
 
92
- // Calculate visible range with scrolling
93
- const startIndex = Math.max(
94
- 0,
95
- Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
96
- );
111
+ // Calculate visible range with scrolling. Selection owns the viewport when
112
+ // present; otherwise navigation moves an independent viewport anchor.
113
+ const startIndex =
114
+ this.#selectedIndex >= 0 ? this.#startIndexForSelection(this.#selectedIndex) : this.#clampedViewportStart();
97
115
  const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
98
116
 
99
117
  // Render visible items
@@ -101,14 +119,16 @@ export class SelectList implements Component {
101
119
  const item = this.#filteredItems[i];
102
120
  if (!item) continue;
103
121
 
104
- const isSelected = i === this.#selectedIndex;
122
+ const isSelected = i === this.#selectedIndex && !item.disabled;
105
123
  const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
106
124
  lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
107
125
  }
108
126
 
109
- // Add scroll indicators if needed
127
+ // Add scroll indicators if needed. With no selectable item the position
128
+ // is reported as "-" so an all-disabled list never claims a selection.
110
129
  if (startIndex > 0 || endIndex < this.#filteredItems.length) {
111
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`;
130
+ const position = this.#selectedIndex >= 0 ? `${this.#selectedIndex + 1}` : "-";
131
+ const scrollText = ` (${position}/${this.#filteredItems.length})`;
112
132
  // Truncate if too long for terminal
113
133
  lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
114
134
  }
@@ -126,30 +146,19 @@ export class SelectList implements Component {
126
146
  }
127
147
  return;
128
148
  }
129
- // Up arrow - wrap to bottom when at top
130
149
  if (kb.matches(keyData, "tui.select.up")) {
131
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
132
- this.#notifySelectionChange();
133
- }
134
- // Down arrow - wrap to top when at bottom
135
- else if (kb.matches(keyData, "tui.select.down")) {
136
- this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
137
- this.#notifySelectionChange();
138
- }
139
- // PageUp - jump up by one visible page
140
- else if (kb.matches(keyData, "tui.select.pageUp")) {
141
- this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
142
- this.#notifySelectionChange();
143
- }
144
- // PageDown - jump down by one visible page
145
- else if (kb.matches(keyData, "tui.select.pageDown")) {
146
- this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
147
- this.#notifySelectionChange();
150
+ this.#moveSelection(-1);
151
+ } else if (kb.matches(keyData, "tui.select.down")) {
152
+ this.#moveSelection(1);
153
+ } else if (kb.matches(keyData, "tui.select.pageUp")) {
154
+ this.#movePage(-1);
155
+ } else if (kb.matches(keyData, "tui.select.pageDown")) {
156
+ this.#movePage(1);
148
157
  }
149
158
  // Enter
150
159
  else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
151
160
  const selectedItem = this.#filteredItems[this.#selectedIndex];
152
- if (selectedItem && this.onSelect) {
161
+ if (selectedItem && !selectedItem.disabled && this.onSelect) {
153
162
  this.onSelect(selectedItem);
154
163
  }
155
164
  }
@@ -184,6 +193,9 @@ export class SelectList implements Component {
184
193
 
185
194
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
186
195
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
196
+ if (item.disabled) {
197
+ return this.theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
198
+ }
187
199
  if (isSelected) {
188
200
  return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
189
201
  }
@@ -195,6 +207,9 @@ export class SelectList implements Component {
195
207
 
196
208
  const maxWidth = width - prefixWidth - 2;
197
209
  const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
210
+ if (item.disabled) {
211
+ return this.theme.description(`${prefix}${truncatedValue}`);
212
+ }
198
213
  if (isSelected) {
199
214
  return this.theme.selectedText(`${prefix}${truncatedValue}`);
200
215
  }
@@ -242,15 +257,105 @@ export class SelectList implements Component {
242
257
  return sanitizeSingleLine(item.label || item.value);
243
258
  }
244
259
 
260
+ /** First enabled index, or `-1` when every filtered item is disabled. */
261
+ #firstEnabledIndex(): number {
262
+ return this.#filteredItems.findIndex(item => !item.disabled);
263
+ }
264
+
265
+ #findEnabledIndex(start: number, direction: 1 | -1, wrap: boolean): number | undefined {
266
+ for (let step = 0; step < this.#filteredItems.length; step++) {
267
+ let index = start + step * direction;
268
+ if (index < 0 || index >= this.#filteredItems.length) {
269
+ if (!wrap) return undefined;
270
+ index = (index + this.#filteredItems.length) % this.#filteredItems.length;
271
+ }
272
+ if (!this.#filteredItems[index]?.disabled) return index;
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ #moveSelection(direction: 1 | -1): void {
278
+ if (this.#filteredItems.length === 0) return;
279
+ if (this.#selectedIndex < 0 && this.#firstEnabledIndex() < 0) {
280
+ this.#moveViewport(direction, true);
281
+ return;
282
+ }
283
+ const start =
284
+ this.#selectedIndex < 0
285
+ ? direction === 1
286
+ ? 0
287
+ : this.#filteredItems.length - 1
288
+ : (this.#selectedIndex + direction + this.#filteredItems.length) % this.#filteredItems.length;
289
+ const next = this.#findEnabledIndex(start, direction, true);
290
+ if (next === undefined) return;
291
+ if (next === this.#selectedIndex) {
292
+ // Preserve the legacy enabled-only callback contract while suppressing
293
+ // no-op previews when disabled entries collapse navigation to one item.
294
+ if (this.#allItemsEnabled()) this.#notifySelectionChange();
295
+ return;
296
+ }
297
+ this.#selectedIndex = next;
298
+ this.#syncViewportToIndex(next);
299
+ this.#notifySelectionChange();
300
+ }
301
+
302
+ #movePage(direction: 1 | -1): void {
303
+ if (this.#filteredItems.length === 0) return;
304
+ if (this.#selectedIndex < 0 && this.#firstEnabledIndex() < 0) {
305
+ this.#moveViewport(direction * this.maxVisible, false);
306
+ return;
307
+ }
308
+ const from = this.#selectedIndex < 0 ? (direction === 1 ? -1 : this.#filteredItems.length) : this.#selectedIndex;
309
+ const target = Math.max(0, Math.min(this.#filteredItems.length - 1, from + direction * this.maxVisible));
310
+ const next =
311
+ this.#findEnabledIndex(target, direction, false) ??
312
+ this.#findEnabledIndex(target, direction === 1 ? -1 : 1, false);
313
+ if (next === undefined) return;
314
+ if (next === this.#selectedIndex) {
315
+ if (this.#allItemsEnabled()) this.#notifySelectionChange();
316
+ return;
317
+ }
318
+ this.#selectedIndex = next;
319
+ this.#syncViewportToIndex(next);
320
+ this.#notifySelectionChange();
321
+ }
322
+
323
+ #allItemsEnabled(): boolean {
324
+ return this.#filteredItems.every(item => !item.disabled);
325
+ }
326
+
327
+ #maxViewportStart(): number {
328
+ return Math.max(0, this.#filteredItems.length - this.maxVisible);
329
+ }
330
+
331
+ #clampedViewportStart(): number {
332
+ return Math.max(0, Math.min(this.#viewportStartIndex, this.#maxViewportStart()));
333
+ }
334
+
335
+ #startIndexForSelection(index: number): number {
336
+ return Math.max(0, Math.min(index - Math.floor(this.maxVisible / 2), this.#maxViewportStart()));
337
+ }
338
+
339
+ #syncViewportToIndex(index: number): void {
340
+ this.#viewportStartIndex = this.#startIndexForSelection(index);
341
+ }
342
+
343
+ #moveViewport(delta: number, wrap: boolean): void {
344
+ const maxStart = this.#maxViewportStart();
345
+ if (maxStart === 0) return;
346
+ const next = this.#viewportStartIndex + delta;
347
+ this.#viewportStartIndex = wrap ? (next + maxStart + 1) % (maxStart + 1) : Math.max(0, Math.min(next, maxStart));
348
+ }
349
+
245
350
  #notifySelectionChange(): void {
246
351
  const selectedItem = this.#filteredItems[this.#selectedIndex];
247
- if (selectedItem && this.onSelectionChange) {
352
+ if (selectedItem && !selectedItem.disabled && this.onSelectionChange) {
248
353
  this.onSelectionChange(selectedItem);
249
354
  }
250
355
  }
251
356
 
252
357
  getSelectedItem(): SelectItem | null {
253
358
  const item = this.#filteredItems[this.#selectedIndex];
254
- return item || null;
359
+ return item && !item.disabled ? item : null;
255
360
  }
256
361
  }
package/src/terminal.ts CHANGED
@@ -171,6 +171,31 @@ function isWindowsSubsystemForLinux(): boolean {
171
171
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
172
172
  }
173
173
  const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
174
+ const stdoutErrorSubscribers = new Set<(err: Error) => void>();
175
+ const dispatchStdoutError = (err: Error): void => {
176
+ for (const subscriber of stdoutErrorSubscribers) subscriber(err);
177
+ };
178
+
179
+ function subscribeToStdoutErrors(subscriber: (err: Error) => void): void {
180
+ if (stdoutErrorSubscribers.size === 0) process.stdout.on("error", dispatchStdoutError);
181
+ stdoutErrorSubscribers.add(subscriber);
182
+ }
183
+
184
+ function unsubscribeFromStdoutErrors(subscriber: (err: Error) => void): void {
185
+ stdoutErrorSubscribers.delete(subscriber);
186
+ if (stdoutErrorSubscribers.size === 0) process.stdout.removeListener("error", dispatchStdoutError);
187
+ }
188
+
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
+ }
174
199
 
175
200
  /**
176
201
  * Real terminal using process.stdin/stdout
@@ -250,7 +275,7 @@ export class ProcessTerminal implements Terminal {
250
275
  this.#stdoutErrorHandler = (err: Error) => {
251
276
  this.#markUnavailable(err, "stdout-error");
252
277
  };
253
- process.stdout.on("error", this.#stdoutErrorHandler);
278
+ subscribeToStdoutErrors(this.#stdoutErrorHandler);
254
279
  }
255
280
 
256
281
  // Refresh terminal dimensions - they may be stale after suspend/resume
@@ -743,7 +768,7 @@ export class ProcessTerminal implements Terminal {
743
768
  // of surfacing as uncaught exceptions that kill the tmux pane.
744
769
  this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
745
770
  if (this.#stdoutErrorHandler) {
746
- process.stdout.removeListener("error", this.#stdoutErrorHandler);
771
+ unsubscribeFromStdoutErrors(this.#stdoutErrorHandler);
747
772
  this.#stdoutErrorHandler = undefined;
748
773
  }
749
774
  this.#stdoutErrorHandlerCleanupTimer = undefined;
package/src/tui.ts CHANGED
@@ -279,31 +279,41 @@ function isViewportSensitiveHost(
279
279
  env: Record<string, string | undefined>,
280
280
  platform: NodeJS.Platform,
281
281
  includeNativeWindows: boolean,
282
+ includeProcessTerminal: boolean,
282
283
  ): boolean {
283
- return isMultiplexerSession(env) || isWindowsTerminalSession(env) || (includeNativeWindows && platform === "win32");
284
+ return (
285
+ isMultiplexerSession(env) ||
286
+ isWindowsTerminalSession(env) ||
287
+ includeProcessTerminal ||
288
+ (includeNativeWindows && platform === "win32")
289
+ );
284
290
  }
285
291
  /**
286
292
  * True when repainting only the live viewport is safer than clearing/replaying
287
- * the full transcript. Native Windows console hosts are included even when
288
- * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
289
- * identity variables while keeping the same scroll-jump behavior.
293
+ * the full transcript. Real process terminals are viewport-sensitive because
294
+ * their native scrollback position is not observable by the renderer. Native
295
+ * Windows console hosts are also recognized from platform identity when that
296
+ * process-terminal capability is unavailable.
290
297
  */
291
298
  export function shouldUseViewportRepaintForHost(
292
299
  env: Record<string, string | undefined> = Bun.env,
293
300
  platform: NodeJS.Platform = process.platform,
294
- options: { includeNativeWindows?: boolean } = {},
301
+ options: { includeNativeWindows?: boolean; includeProcessTerminal?: boolean } = {},
295
302
  ): boolean {
296
303
  const multiplexed = isMultiplexerSession(env);
297
304
  const includeNativeWindows = options.includeNativeWindows ?? true;
305
+ const includeProcessTerminal = options.includeProcessTerminal ?? false;
298
306
  return (
299
- isViewportSensitiveHost(env, platform, includeNativeWindows) &&
307
+ isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal) &&
300
308
  !(multiplexed && useLegacyMultiplexerFullRender(env))
301
309
  );
302
310
  }
303
311
 
304
312
  function useViewportRepaintPath(terminal: Terminal): boolean {
313
+ if (terminal.isProcessTerminal !== true) return false;
305
314
  return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
306
- includeNativeWindows: terminal.isProcessTerminal === true,
315
+ includeNativeWindows: true,
316
+ includeProcessTerminal: true,
307
317
  });
308
318
  }
309
319
 
@@ -319,7 +329,8 @@ function allowsHostNeutralOverflowRepaint(
319
329
  }
320
330
 
321
331
  function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
322
- return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
332
+ if (terminal.isProcessTerminal !== true) return false;
333
+ return isViewportSensitiveHost(Bun.env, process.platform, true, true);
323
334
  }
324
335
 
325
336
  /**
@@ -618,6 +629,7 @@ export class TUI extends Container {
618
629
  #stopped = false;
619
630
  #terminalUnavailable = false;
620
631
  #bottomPinnedComponent: Component | null = null;
632
+ #pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
621
633
 
622
634
  #unsubscribeTabWidthChange?: () => void;
623
635
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -962,6 +974,7 @@ export class TUI extends Container {
962
974
  this.requestResizeRender();
963
975
  },
964
976
  );
977
+ this.flushTerminalCleanup();
965
978
  this.#hideCursor();
966
979
  this.#querySixelSupport();
967
980
  this.#queryCellSize();
@@ -1171,6 +1184,7 @@ export class TUI extends Container {
1171
1184
  }
1172
1185
 
1173
1186
  stop(): void {
1187
+ this.flushTerminalCleanup();
1174
1188
  this.#clearSixelProbeState();
1175
1189
  this.#stopped = true;
1176
1190
  if (this.#renderTimer) {
@@ -1229,7 +1243,7 @@ export class TUI extends Container {
1229
1243
  * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
1230
1244
  */
1231
1245
  requestResizeRender(): void {
1232
- this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
1246
+ this.requestRender(!useViewportRepaintPath(this.terminal), "resize");
1233
1247
  }
1234
1248
 
1235
1249
  requestRender(force = false, source = "unknown"): void {
@@ -2274,11 +2288,9 @@ export class TUI extends Container {
2274
2288
  viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
2275
2289
  return;
2276
2290
  }
2277
- if (!isTermuxSession() && !isMultiplexerSession()) {
2278
- logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
2279
- fullRender(true, "terminal height changed");
2280
- return;
2281
- }
2291
+ logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
2292
+ fullRender(true, "terminal height changed");
2293
+ return;
2282
2294
  }
2283
2295
 
2284
2296
  // Content shrunk below the previous render and no overlays - re-render to clear empty rows
@@ -2333,8 +2345,8 @@ export class TUI extends Container {
2333
2345
  }
2334
2346
 
2335
2347
  const nextLiveViewportTop = Math.max(0, newLines.length - height);
2336
- if (firstChanged >= newLines.length && nextLiveViewportTop !== prevViewportTop) {
2337
- viewportRepaint(`tail shrink changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2348
+ if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
2349
+ viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2338
2350
  return;
2339
2351
  }
2340
2352
  // All changes are in deleted lines (nothing to render, just clear)
@@ -2580,6 +2592,22 @@ export class TUI extends Container {
2580
2592
  return { seq, toRow: targetRow };
2581
2593
  }
2582
2594
 
2595
+ /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
2596
+ queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
2597
+ this.#pendingTerminalCleanup.push({ payload, onDelivered });
2598
+ this.flushTerminalCleanup();
2599
+ }
2600
+
2601
+ /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
2602
+ flushTerminalCleanup(): void {
2603
+ while (this.#pendingTerminalCleanup.length > 0) {
2604
+ const pending = this.#pendingTerminalCleanup[0];
2605
+ if (!this.#writeTerminal(pending.payload)) return;
2606
+ this.#pendingTerminalCleanup.shift();
2607
+ pending.onDelivered?.();
2608
+ }
2609
+ }
2610
+
2583
2611
  /**
2584
2612
  * Register an emitter whose escape payload is appended to every render
2585
2613
  * write (inside its own synchronized-output block, cursor saved/restored).