@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 +11 -0
- package/README.md +18 -1
- package/dist/types/components/select-list.d.ts +7 -1
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/tui.d.ts +9 -3
- package/package.json +3 -3
- package/src/components/select-list.ts +138 -33
- package/src/terminal.ts +27 -2
- package/src/tui.ts +44 -16
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
|
-
/**
|
|
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;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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.
|
|
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.
|
|
39
|
-
"@gajae-code/utils": "0.10.
|
|
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
|
-
/**
|
|
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
|
-
|
|
70
|
-
this.#selectedIndex
|
|
80
|
+
this.#selectedIndex = this.#firstEnabledIndex();
|
|
81
|
+
this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
setSelectedIndex(index: number): void {
|
|
74
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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.#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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 (
|
|
2337
|
-
viewportRepaint(`
|
|
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).
|