@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 +18 -0
- package/README.md +18 -1
- package/dist/types/components/select-list.d.ts +7 -1
- package/dist/types/terminal-capabilities.d.ts +23 -0
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/tui.d.ts +21 -3
- package/package.json +3 -3
- package/src/components/select-list.ts +138 -33
- package/src/terminal-capabilities.ts +77 -4
- package/src/terminal.ts +27 -2
- package/src/tui.ts +94 -37
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
|
-
/**
|
|
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 {};
|
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
|
@@ -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.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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.
|
|
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
|
}
|
|
@@ -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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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.
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1087
|
+
const params = (match[1] ?? "")
|
|
1053
1088
|
.split(";")
|
|
1054
1089
|
.map(value => Number.parseInt(value, 10))
|
|
1055
1090
|
.filter(value => Number.isFinite(value));
|
|
1056
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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 (
|
|
2308
|
-
viewportRepaint(`
|
|
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).
|