@gajae-code/tui 0.10.0 → 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,6 +2,24 @@
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
+
15
+ ## [0.10.1] - 2026-07-13
16
+ ### Fixed
17
+
18
+ - Real interactive terminals now repaint only the visible viewport during forced renders instead of clearing and replaying native scrollback.
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.
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.
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.
22
+
5
23
  ## [0.10.0] - 2026-07-12
6
24
  ### Fixed
7
25
 
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;
@@ -22,6 +22,15 @@ export declare class TerminalInfo {
22
22
  sendNotification(message: string): void;
23
23
  }
24
24
  export declare function isNotificationSuppressed(): boolean;
25
+ /**
26
+ * Returns whether the process runs under a terminal multiplexer (tmux, GNU
27
+ * screen, or zellij). Recognizes the same host markers as the renderer's
28
+ * multiplexer predicate in tui.ts so capability selection and viewport-repaint
29
+ * policy agree on what counts as a multiplexed host. Multiplexers intercept
30
+ * graphics escapes and OSC 8 hyperlinks instead of forwarding them to the
31
+ * outer terminal.
32
+ */
33
+ export declare function isUnderTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
25
34
  export interface TerminalGraphicsFallbackOptions {
26
35
  /**
27
36
  * Permit cursor-neutral image escapes (kitty `a=p,C=1` placements) to render
@@ -43,6 +52,12 @@ export declare function isTerminalGraphicsFallbackActive(): boolean;
43
52
  * graphics-fallback scope. True only when every active fallback scope opted in.
44
53
  */
45
54
  export declare function isCursorNeutralImagePermittedInFallback(): boolean;
55
+ /**
56
+ * Returns whether PI_FORCE_IMAGE_PROTOCOL explicitly configures the image
57
+ * protocol, including an explicit "off". An explicit configuration is
58
+ * authoritative: runtime capability probes must not override it.
59
+ */
60
+ export declare function isImageProtocolForced(): boolean;
46
61
  /**
47
62
  * Returns true when running in Windows Terminal with known SIXEL support.
48
63
  *
@@ -51,6 +66,13 @@ export declare function isCursorNeutralImagePermittedInFallback(): boolean;
51
66
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
52
67
  export declare const TERMINAL_ID: TerminalId;
53
68
  export declare const TERMINAL: TerminalInfo;
69
+ type ImageProtocolChangeListener = (imageProtocol: ImageProtocol | null) => void;
70
+ /**
71
+ * Subscribe to runtime image-protocol changes (e.g. the asynchronous sixel
72
+ * capability probe enabling graphics after startup). Returns an unsubscribe
73
+ * function. Listeners fire only on actual changes.
74
+ */
75
+ export declare function onImageProtocolChanged(listener: ImageProtocolChangeListener): () => void;
54
76
  /**
55
77
  * Override terminal image protocol at runtime after capability probes complete.
56
78
  */
@@ -162,3 +184,4 @@ export interface RenderedImage {
162
184
  }
163
185
  export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): RenderedImage | null;
164
186
  export declare function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string;
187
+ export {};
@@ -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
  */
@@ -98,14 +98,28 @@ export interface OverlayMargin {
98
98
  }
99
99
  /** Value that can be absolute (number) or percentage (string like "50%") */
100
100
  export type SizeValue = number | `${number}%`;
101
+ /**
102
+ * Startup sixel capability probe policy (pure; exported for tests):
103
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
104
+ * configuration (including "off") is authoritative.
105
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
106
+ * whenever it was compiled with sixel support, regardless of whether the
107
+ * attached client terminal can render sixel, so a positive reply is not
108
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
109
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
110
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
111
+ */
112
+ export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
101
113
  /**
102
114
  * True when repainting only the live viewport is safer than clearing/replaying
103
- * the full transcript. Native Windows console hosts are included even when
104
- * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
105
- * 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.
106
119
  */
107
120
  export declare function shouldUseViewportRepaintForHost(env?: Record<string, string | undefined>, platform?: NodeJS.Platform, options?: {
108
121
  includeNativeWindows?: boolean;
122
+ includeProcessTerminal?: boolean;
109
123
  }): boolean;
110
124
  /**
111
125
  * Options for overlay positioning and sizing.
@@ -247,6 +261,10 @@ export declare class TUI extends Container {
247
261
  normalizationLimit: number;
248
262
  truncationLimit: number;
249
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;
250
268
  /**
251
269
  * Register an emitter whose escape payload is appended to every render
252
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.0",
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.0",
39
- "@gajae-code/utils": "0.10.0",
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
  }
@@ -53,6 +53,35 @@ export function isNotificationSuppressed(): boolean {
53
53
  return value === "off" || value === "0" || value === "false";
54
54
  }
55
55
 
56
+ const MULTIPLEXER_DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
57
+
58
+ function multiplexerEnvEnabled(value: string | undefined): boolean {
59
+ const normalized = value?.trim().toLowerCase();
60
+ return normalized !== undefined && normalized.length > 0 && !MULTIPLEXER_DISABLED_ENV_VALUES.has(normalized);
61
+ }
62
+
63
+ /**
64
+ * Returns whether the process runs under a terminal multiplexer (tmux, GNU
65
+ * screen, or zellij). Recognizes the same host markers as the renderer's
66
+ * multiplexer predicate in tui.ts so capability selection and viewport-repaint
67
+ * policy agree on what counts as a multiplexed host. Multiplexers intercept
68
+ * graphics escapes and OSC 8 hyperlinks instead of forwarding them to the
69
+ * outer terminal.
70
+ */
71
+ export function isUnderTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
72
+ if (
73
+ multiplexerEnvEnabled(env.TMUX) ||
74
+ multiplexerEnvEnabled(env.TMUX_PANE) ||
75
+ multiplexerEnvEnabled(env.STY) ||
76
+ multiplexerEnvEnabled(env.ZELLIJ) ||
77
+ multiplexerEnvEnabled(env.GJC_TMUX_LAUNCHED)
78
+ ) {
79
+ return true;
80
+ }
81
+ const term = env.TERM?.trim().toLowerCase() ?? "";
82
+ return term.startsWith("tmux") || term.startsWith("screen");
83
+ }
84
+
56
85
  let terminalGraphicsFallbackDepth = 0;
57
86
  let cursorNeutralImageAllowedDepth = 0;
58
87
 
@@ -105,6 +134,15 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
105
134
  return null;
106
135
  }
107
136
 
137
+ /**
138
+ * Returns whether PI_FORCE_IMAGE_PROTOCOL explicitly configures the image
139
+ * protocol, including an explicit "off". An explicit configuration is
140
+ * authoritative: runtime capability probes must not override it.
141
+ */
142
+ export function isImageProtocolForced(): boolean {
143
+ return getForcedImageProtocol() !== undefined;
144
+ }
145
+
108
146
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
109
147
  if (!versionRaw) return null;
110
148
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
@@ -137,7 +175,7 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
137
175
  if (!process.stdout.isTTY) return null;
138
176
  if (terminalId === "vscode" || terminalId === "alacritty") return null;
139
177
  const term = Bun.env.TERM?.toLowerCase() ?? "";
140
- if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
178
+ if (term.includes("ghostty")) {
141
179
  return ImageProtocol.Kitty;
142
180
  }
143
181
  return null;
@@ -220,10 +258,10 @@ export const TERMINAL = (() => {
220
258
  );
221
259
  }
222
260
  }
261
+ const underMultiplexer = isUnderTerminalMultiplexer();
223
262
  // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
224
263
  // to the outer terminal, so force them off regardless of detected terminal.
225
- const term = Bun.env.TERM?.toLowerCase() ?? "";
226
- if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
264
+ if (resolved.hyperlinks && underMultiplexer) {
227
265
  resolved = new TerminalInfo(
228
266
  resolved.id,
229
267
  resolved.imageProtocol,
@@ -232,6 +270,17 @@ export const TERMINAL = (() => {
232
270
  resolved.notifyProtocol,
233
271
  );
234
272
  }
273
+ // Multiplexers (tmux/screen/zellij) consume raw kitty/iTerm2 graphics
274
+ // escapes instead of forwarding them (no DCS passthrough wrapping is
275
+ // emitted), so a detected image protocol draws nothing while its
276
+ // out-of-band cursor writes corrupt the frame. Graphics are therefore
277
+ // unconditionally suppressed under a multiplexer; the runtime sixel probe
278
+ // never runs there (tmux advertises DA1 ";4" from compile-time support
279
+ // regardless of the attached client), and PI_FORCE_IMAGE_PROTOCOL=sixel
280
+ // is the only opt-in for chains that render sixel end-to-end.
281
+ if (resolved.imageProtocol && forcedImageProtocol === undefined && underMultiplexer) {
282
+ resolved = new TerminalInfo(resolved.id, null, resolved.trueColor, resolved.hyperlinks, resolved.notifyProtocol);
283
+ }
235
284
  return resolved;
236
285
  })();
237
286
 
@@ -239,11 +288,35 @@ type MutableTerminalInfo = {
239
288
  imageProtocol: ImageProtocol | null;
240
289
  };
241
290
 
291
+ type ImageProtocolChangeListener = (imageProtocol: ImageProtocol | null) => void;
292
+ const imageProtocolChangeListeners = new Set<ImageProtocolChangeListener>();
293
+
294
+ /**
295
+ * Subscribe to runtime image-protocol changes (e.g. the asynchronous sixel
296
+ * capability probe enabling graphics after startup). Returns an unsubscribe
297
+ * function. Listeners fire only on actual changes.
298
+ */
299
+ export function onImageProtocolChanged(listener: ImageProtocolChangeListener): () => void {
300
+ imageProtocolChangeListeners.add(listener);
301
+ return () => {
302
+ imageProtocolChangeListeners.delete(listener);
303
+ };
304
+ }
305
+
242
306
  /**
243
307
  * Override terminal image protocol at runtime after capability probes complete.
244
308
  */
245
309
  export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
246
- (TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol;
310
+ const mutable = TERMINAL as unknown as MutableTerminalInfo;
311
+ if (mutable.imageProtocol === imageProtocol) return;
312
+ mutable.imageProtocol = imageProtocol;
313
+ for (const listener of imageProtocolChangeListeners) {
314
+ try {
315
+ listener(imageProtocol);
316
+ } catch {
317
+ // Listener failures must not break protocol switching.
318
+ }
319
+ }
247
320
  }
248
321
 
249
322
  export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
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
@@ -9,7 +9,14 @@ import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
- import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
12
+ import {
13
+ ImageProtocol,
14
+ isImageProtocolForced,
15
+ isUnderTerminalMultiplexer,
16
+ setCellDimensions,
17
+ setTerminalImageProtocol,
18
+ TERMINAL,
19
+ } from "./terminal-capabilities";
13
20
  import {
14
21
  Ellipsis,
15
22
  extractSegments,
@@ -217,7 +224,6 @@ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boo
217
224
  return Boolean(env.TERMUX_VERSION);
218
225
  }
219
226
 
220
- const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
221
227
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
222
228
  const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
223
229
 
@@ -231,25 +237,38 @@ function envFlagEnabled(value: string | undefined): boolean {
231
237
  return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
232
238
  }
233
239
 
234
- function termLooksMultiplexed(value: string | undefined): boolean {
235
- const term = value?.trim().toLowerCase() ?? "";
236
- return term.startsWith("tmux") || term.startsWith("screen");
237
- }
238
-
239
240
  function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
240
241
  return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
241
242
  }
242
243
 
243
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
244
+ /**
245
+ * Detect terminal multiplexers where scrollback clearing and height-change
246
+ * redraws are hostile. Delegates to the shared capability predicate so the
247
+ * renderer and graphics-protocol selection agree on what counts as a
248
+ * multiplexed host.
249
+ */
244
250
  function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
245
- return Boolean(
246
- envIsEnabled(env.TMUX) ||
247
- envIsEnabled(env.TMUX_PANE) ||
248
- envIsEnabled(env.STY) ||
249
- envIsEnabled(env.ZELLIJ) ||
250
- envIsEnabled(env[GJC_TMUX_LAUNCHED_ENV]) ||
251
- termLooksMultiplexed(env.TERM),
252
- );
251
+ return isUnderTerminalMultiplexer(env as NodeJS.ProcessEnv);
252
+ }
253
+
254
+ /**
255
+ * Startup sixel capability probe policy (pure; exported for tests):
256
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
257
+ * configuration (including "off") is authoritative.
258
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
259
+ * whenever it was compiled with sixel support, regardless of whether the
260
+ * attached client terminal can render sixel, so a positive reply is not
261
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
262
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
263
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
264
+ */
265
+ export function shouldProbeSixelCapability(
266
+ env: NodeJS.ProcessEnv = Bun.env,
267
+ platform: NodeJS.Platform = process.platform,
268
+ ): boolean {
269
+ if (isImageProtocolForced()) return false;
270
+ if (isUnderTerminalMultiplexer(env)) return false;
271
+ return platform === "win32" && Boolean(env.WT_SESSION?.trim());
253
272
  }
254
273
 
255
274
  function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
@@ -260,31 +279,41 @@ function isViewportSensitiveHost(
260
279
  env: Record<string, string | undefined>,
261
280
  platform: NodeJS.Platform,
262
281
  includeNativeWindows: boolean,
282
+ includeProcessTerminal: boolean,
263
283
  ): boolean {
264
- return isMultiplexerSession(env) || isWindowsTerminalSession(env) || (includeNativeWindows && platform === "win32");
284
+ return (
285
+ isMultiplexerSession(env) ||
286
+ isWindowsTerminalSession(env) ||
287
+ includeProcessTerminal ||
288
+ (includeNativeWindows && platform === "win32")
289
+ );
265
290
  }
266
291
  /**
267
292
  * True when repainting only the live viewport is safer than clearing/replaying
268
- * the full transcript. Native Windows console hosts are included even when
269
- * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
270
- * 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.
271
297
  */
272
298
  export function shouldUseViewportRepaintForHost(
273
299
  env: Record<string, string | undefined> = Bun.env,
274
300
  platform: NodeJS.Platform = process.platform,
275
- options: { includeNativeWindows?: boolean } = {},
301
+ options: { includeNativeWindows?: boolean; includeProcessTerminal?: boolean } = {},
276
302
  ): boolean {
277
303
  const multiplexed = isMultiplexerSession(env);
278
304
  const includeNativeWindows = options.includeNativeWindows ?? true;
305
+ const includeProcessTerminal = options.includeProcessTerminal ?? false;
279
306
  return (
280
- isViewportSensitiveHost(env, platform, includeNativeWindows) &&
307
+ isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal) &&
281
308
  !(multiplexed && useLegacyMultiplexerFullRender(env))
282
309
  );
283
310
  }
284
311
 
285
312
  function useViewportRepaintPath(terminal: Terminal): boolean {
313
+ if (terminal.isProcessTerminal !== true) return false;
286
314
  return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
287
- includeNativeWindows: terminal.isProcessTerminal === true,
315
+ includeNativeWindows: true,
316
+ includeProcessTerminal: true,
288
317
  });
289
318
  }
290
319
 
@@ -300,7 +329,8 @@ function allowsHostNeutralOverflowRepaint(
300
329
  }
301
330
 
302
331
  function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
303
- 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);
304
334
  }
305
335
 
306
336
  /**
@@ -599,6 +629,7 @@ export class TUI extends Container {
599
629
  #stopped = false;
600
630
  #terminalUnavailable = false;
601
631
  #bottomPinnedComponent: Component | null = null;
632
+ #pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
602
633
 
603
634
  #unsubscribeTabWidthChange?: () => void;
604
635
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -943,6 +974,7 @@ export class TUI extends Container {
943
974
  this.requestResizeRender();
944
975
  },
945
976
  );
977
+ this.flushTerminalCleanup();
946
978
  this.#hideCursor();
947
979
  this.#querySixelSupport();
948
980
  this.#queryCellSize();
@@ -1008,8 +1040,7 @@ export class TUI extends Container {
1008
1040
 
1009
1041
  #querySixelSupport(): void {
1010
1042
  if (TERMINAL.imageProtocol) return;
1011
- if (process.platform !== "win32") return;
1012
- if (!Bun.env.WT_SESSION) return;
1043
+ if (!this.#isSixelProbeCandidate()) return;
1013
1044
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
1014
1045
 
1015
1046
  this.#clearSixelProbeState();
@@ -1023,6 +1054,10 @@ export class TUI extends Container {
1023
1054
  }, 250);
1024
1055
  }
1025
1056
 
1057
+ #isSixelProbeCandidate(): boolean {
1058
+ return shouldProbeSixelCapability();
1059
+ }
1060
+
1026
1061
  #handleSixelProbeInput(data: string): InputListenerResult {
1027
1062
  if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
1028
1063
  return undefined;
@@ -1049,11 +1084,15 @@ export class TUI extends Container {
1049
1084
 
1050
1085
  if (useDa && this.#sixelProbePendingDa) {
1051
1086
  this.#sixelProbePendingDa = false;
1052
- const attributes = (match[1] ?? "")
1087
+ const params = (match[1] ?? "")
1053
1088
  .split(";")
1054
1089
  .map(value => Number.parseInt(value, 10))
1055
1090
  .filter(value => Number.isFinite(value));
1056
- const hasSixelAttribute = attributes.includes(4);
1091
+ // The first DA1 parameter is the device/operating class (e.g. 1,
1092
+ // 62, 64), not an extension attribute: `CSI ?4;6c` identifies a
1093
+ // VT132, it does not advertise sixel. Only the parameters after
1094
+ // the class carry attributes like 4 (sixel graphics).
1095
+ const hasSixelAttribute = params.slice(1).includes(4);
1057
1096
  if (hasSixelAttribute) {
1058
1097
  this.#sixelProbePendingGraphics = false;
1059
1098
  probeOutcome = true;
@@ -1062,8 +1101,11 @@ export class TUI extends Container {
1062
1101
  }
1063
1102
  } else if (!useDa && this.#sixelProbePendingGraphics) {
1064
1103
  this.#sixelProbePendingGraphics = false;
1104
+ // XTSMGRAPHICS reply is `CSI ? 2 ; Ps ; ... S` where Ps=0 means
1105
+ // success and 1/2/3 are errors (tmux answers our unsupported
1106
+ // read with `CSI ?2;3;0S`). Only a success reply proves sixel.
1065
1107
  const status = Number.parseInt(match[1] ?? "", 10);
1066
- const supportsSixel = !Number.isNaN(status) && status !== 0;
1108
+ const supportsSixel = status === 0;
1067
1109
  if (supportsSixel) {
1068
1110
  this.#sixelProbePendingDa = false;
1069
1111
  probeOutcome = true;
@@ -1142,6 +1184,7 @@ export class TUI extends Container {
1142
1184
  }
1143
1185
 
1144
1186
  stop(): void {
1187
+ this.flushTerminalCleanup();
1145
1188
  this.#clearSixelProbeState();
1146
1189
  this.#stopped = true;
1147
1190
  if (this.#renderTimer) {
@@ -1200,7 +1243,7 @@ export class TUI extends Container {
1200
1243
  * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
1201
1244
  */
1202
1245
  requestResizeRender(): void {
1203
- this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
1246
+ this.requestRender(!useViewportRepaintPath(this.terminal), "resize");
1204
1247
  }
1205
1248
 
1206
1249
  requestRender(force = false, source = "unknown"): void {
@@ -2245,11 +2288,9 @@ export class TUI extends Container {
2245
2288
  viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
2246
2289
  return;
2247
2290
  }
2248
- if (!isTermuxSession() && !isMultiplexerSession()) {
2249
- logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
2250
- fullRender(true, "terminal height changed");
2251
- return;
2252
- }
2291
+ logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
2292
+ fullRender(true, "terminal height changed");
2293
+ return;
2253
2294
  }
2254
2295
 
2255
2296
  // Content shrunk below the previous render and no overlays - re-render to clear empty rows
@@ -2304,8 +2345,8 @@ export class TUI extends Container {
2304
2345
  }
2305
2346
 
2306
2347
  const nextLiveViewportTop = Math.max(0, newLines.length - height);
2307
- if (firstChanged >= newLines.length && nextLiveViewportTop !== prevViewportTop) {
2308
- 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})`);
2309
2350
  return;
2310
2351
  }
2311
2352
  // All changes are in deleted lines (nothing to render, just clear)
@@ -2551,6 +2592,22 @@ export class TUI extends Container {
2551
2592
  return { seq, toRow: targetRow };
2552
2593
  }
2553
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
+
2554
2611
  /**
2555
2612
  * Register an emitter whose escape payload is appended to every render
2556
2613
  * write (inside its own synchronized-output block, cursor saved/restored).