@gajae-code/tui 0.12.11 → 0.12.12
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 +8 -0
- package/dist/types/animation-scheduler.d.ts +3 -0
- package/dist/types/components/loader.d.ts +12 -0
- package/dist/types/terminal-capabilities.d.ts +2 -0
- package/dist/types/tui.d.ts +12 -0
- package/package.json +3 -3
- package/src/animation-scheduler.ts +39 -0
- package/src/components/loader.ts +31 -13
- package/src/components/settings-list.ts +65 -17
- package/src/terminal-capabilities.ts +5 -0
- package/src/terminal.ts +32 -22
- package/src/tui.ts +51 -24
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.12] - 2026-08-05
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
- Restored 60 fps time-dependent loader gradients on direct local terminals while retaining the 80 ms cadence and congestion dropping on SSH and multiplexed terminals.
|
|
9
|
+
- Bounded loader animation scheduling to 80 ms, stopped OSC 11 polling after DA1 proves the query unsupported while preserving Mode 2031 recovery, and added the default-on `GJC_TUI_SYNCHRONIZED_OUTPUT=0` compatibility opt-out for terminal parsers that mishandle synchronized-output framing. Real iOS-client validation remains pending for #3798.
|
|
10
|
+
- Decorative animation ticks now pause while stdout has more than 64 KiB buffered, preventing slow SSH terminals and multiplexers from accumulating stale spinner frames.
|
|
11
|
+
|
|
5
12
|
## [0.12.11] - 2026-08-03
|
|
6
13
|
|
|
7
14
|
## [0.12.10] - 2026-08-03
|
|
@@ -22,6 +29,7 @@
|
|
|
22
29
|
- Restored the `isProcessTerminal`/`shouldUseViewportRepaintForHost` gate on the width-change viewport-repaint intercept so plain terminals (non-multiplexer, non-process-terminal) use `fullRender` for width changes instead of an unconditional viewport repaint. The #3684 chain removed this gate, causing lossless Korean/CJK prose wrapping to break at narrow widths because the viewport repaint only painted the visible rows without committing the full transcript to scrollback (#1979).
|
|
23
30
|
- Restored the `fullRender` fallback for the `firstChanged < viewportTop` branch on non-viewport-repaint hosts, so above-viewport mutations replay the full frame instead of silently viewport-repainting.
|
|
24
31
|
- Propagated IME cursor write failure from `#writeRenderBufferAndReanchorImeCursor` so callers detect terminal detach when the deferred cursor write fails after the shared frame commits.
|
|
32
|
+
- The win32 viewport-repaint fallback no longer outranks a terminal that reports `isProcessTerminal: false`. Platform identity exists to recognize Windows console hosts that cannot report the capability, so a terminal that has answered now decides; previously every non-process terminal on Windows (embedders, pipes, the render regression suite) inherited viewport-repaint semantics, suppressed durable history replay, and left contracted rows behind as duplicates.
|
|
25
33
|
|
|
26
34
|
## [0.12.7] - 2026-07-31
|
|
27
35
|
|
|
@@ -8,6 +8,9 @@ export declare const __animationSchedulerTestHooks: {
|
|
|
8
8
|
getActiveTimerCount(cadence?: AnimationCadence): number;
|
|
9
9
|
getRegistrantCount(cadence?: AnimationCadence): number;
|
|
10
10
|
getStartedTimerCount(cadence?: AnimationCadence): number;
|
|
11
|
+
getSkippedTickCount(): number;
|
|
12
|
+
getCongestionThresholdBytes(): number;
|
|
13
|
+
setBufferedOutputBytesProbe(probe: (() => number | undefined) | undefined): void;
|
|
11
14
|
reset(): void;
|
|
12
15
|
};
|
|
13
16
|
export {};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { TUI } from "../tui";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
/**
|
|
4
|
+
* Compatibility options for existing loader call sites.
|
|
5
|
+
*
|
|
6
|
+
* @deprecated `timeDependentColor` preserves the historical smooth-animation
|
|
7
|
+
* contract: direct local terminals reevaluate colorizers at 60 fps, while
|
|
8
|
+
* remote or multiplexed terminals stay on the shared 80 ms cadence to avoid
|
|
9
|
+
* output churn. The field remains accepted for downstream callers while they
|
|
10
|
+
* migrate to an explicit animation policy.
|
|
11
|
+
*/
|
|
3
12
|
export interface LoaderOptions {
|
|
4
13
|
timeDependentColor?: boolean;
|
|
5
14
|
}
|
|
@@ -7,6 +16,8 @@ export interface LoaderOptions {
|
|
|
7
16
|
export declare const __loaderPerfCounters: {
|
|
8
17
|
liveIntervals: number;
|
|
9
18
|
startedIntervals: number;
|
|
19
|
+
callbackInvocations: number;
|
|
20
|
+
renderRequests: number;
|
|
10
21
|
reset(): void;
|
|
11
22
|
};
|
|
12
23
|
export declare class Loader extends Text {
|
|
@@ -14,6 +25,7 @@ export declare class Loader extends Text {
|
|
|
14
25
|
private spinnerColorFn;
|
|
15
26
|
private messageColorFn;
|
|
16
27
|
private message;
|
|
28
|
+
private options;
|
|
17
29
|
constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[], options?: LoaderOptions);
|
|
18
30
|
render(width: number): string[];
|
|
19
31
|
start(): void;
|
|
@@ -22,6 +22,8 @@ export declare class TerminalInfo {
|
|
|
22
22
|
sendNotification(message: string): void;
|
|
23
23
|
}
|
|
24
24
|
export declare function isNotificationSuppressed(): boolean;
|
|
25
|
+
/** Returns whether stdout crosses an SSH transport, where animation bytes can outpace the link. */
|
|
26
|
+
export declare function isRemoteTerminalSession(env?: NodeJS.ProcessEnv): boolean;
|
|
25
27
|
/**
|
|
26
28
|
* Returns whether the process runs under a terminal multiplexer (tmux, GNU
|
|
27
29
|
* screen, or zellij). Recognizes the same host markers as the renderer's
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -179,6 +179,18 @@ export declare function shouldUseViewportRepaintForHost(env?: Record<string, str
|
|
|
179
179
|
includeNativeWindows?: boolean;
|
|
180
180
|
includeProcessTerminal?: boolean;
|
|
181
181
|
}): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Viewport-repaint host gate resolved against a terminal's reported capability.
|
|
184
|
+
*
|
|
185
|
+
* `includeNativeWindows` exists so a Windows console host that cannot report
|
|
186
|
+
* `isProcessTerminal` is still recognized from platform identity. It is a
|
|
187
|
+
* fallback, so it must not outrank a terminal that has answered: a terminal
|
|
188
|
+
* reporting `false` is not a native console host, and letting win32 override it
|
|
189
|
+
* gives every non-process terminal on Windows — embedders, pipes, and the
|
|
190
|
+
* render regression suite — viewport-repaint semantics. Those hosts then never
|
|
191
|
+
* replay durable history, so contracted rows survive as duplicates.
|
|
192
|
+
*/
|
|
193
|
+
export declare function shouldUseViewportRepaintForTerminal(isProcessTerminal: boolean | undefined, env?: Record<string, string | undefined>, platform?: NodeJS.Platform): boolean;
|
|
182
194
|
/**
|
|
183
195
|
* Options for overlay positioning and sizing.
|
|
184
196
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.12",
|
|
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",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.12.
|
|
40
|
-
"@gajae-code/utils": "0.12.
|
|
39
|
+
"@gajae-code/natives": "0.12.12",
|
|
40
|
+
"@gajae-code/utils": "0.12.12",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
|
@@ -9,6 +9,28 @@ interface CadenceBucket {
|
|
|
9
9
|
startedTimers: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Animation ticks are decorative. When the output sink cannot keep up — a
|
|
13
|
+
// remote terminal over SSH, a multiplexer, a slow pipe — those frames are not
|
|
14
|
+
// merely wasted, they queue. Bun buffers whatever `write()` could not hand to
|
|
15
|
+
// the OS, so the renderer runs ahead of the wire and the user watches a backlog
|
|
16
|
+
// drain instead of the current frame.
|
|
17
|
+
//
|
|
18
|
+
// Dropping a decorative tick loses nothing: the next one redraws from live state.
|
|
19
|
+
// Skipping is therefore always safe here, and must never be extended to renders
|
|
20
|
+
// that carry content, which the diff renderer must still emit in order.
|
|
21
|
+
const DEFAULT_CONGESTION_THRESHOLD_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
let bufferedOutputBytesProbe: (() => number | undefined) | undefined;
|
|
24
|
+
let skippedTicks = 0;
|
|
25
|
+
|
|
26
|
+
function outputIsCongested(): boolean {
|
|
27
|
+
const stdout = globalThis.process?.stdout as { writableLength?: number } | undefined;
|
|
28
|
+
const buffered = bufferedOutputBytesProbe ? bufferedOutputBytesProbe() : stdout?.writableLength;
|
|
29
|
+
// A healthy TTY drains synchronously and reports 0 here, so this is a no-op
|
|
30
|
+
// locally and only engages once bytes are genuinely stuck.
|
|
31
|
+
return typeof buffered === "number" && buffered > DEFAULT_CONGESTION_THRESHOLD_BYTES;
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
const buckets = new Map<AnimationCadence, CadenceBucket>();
|
|
13
35
|
|
|
14
36
|
function getBucket(cadence: AnimationCadence): CadenceBucket {
|
|
@@ -23,6 +45,12 @@ function getBucket(cadence: AnimationCadence): CadenceBucket {
|
|
|
23
45
|
function startBucket(cadence: AnimationCadence, bucket: CadenceBucket): void {
|
|
24
46
|
if (bucket.timer) return;
|
|
25
47
|
bucket.timer = setInterval(() => {
|
|
48
|
+
// Skip the whole tick, not each callback: the decision is about the shared
|
|
49
|
+
// output sink, so sampling it once keeps every registrant on the same frame.
|
|
50
|
+
if (outputIsCongested()) {
|
|
51
|
+
skippedTicks += 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
26
54
|
const now = performance.now();
|
|
27
55
|
// Snapshot so re-entrant register/unregister during a tick is safe, and
|
|
28
56
|
// isolate each callback so one throwing registrant cannot starve siblings
|
|
@@ -89,11 +117,22 @@ export const __animationSchedulerTestHooks = {
|
|
|
89
117
|
for (const bucket of buckets.values()) count += bucket.startedTimers;
|
|
90
118
|
return count;
|
|
91
119
|
},
|
|
120
|
+
getSkippedTickCount(): number {
|
|
121
|
+
return skippedTicks;
|
|
122
|
+
},
|
|
123
|
+
getCongestionThresholdBytes(): number {
|
|
124
|
+
return DEFAULT_CONGESTION_THRESHOLD_BYTES;
|
|
125
|
+
},
|
|
126
|
+
setBufferedOutputBytesProbe(probe: (() => number | undefined) | undefined): void {
|
|
127
|
+
bufferedOutputBytesProbe = probe;
|
|
128
|
+
},
|
|
92
129
|
reset(): void {
|
|
93
130
|
for (const bucket of buckets.values()) {
|
|
94
131
|
stopBucket(bucket);
|
|
95
132
|
bucket.callbacks.clear();
|
|
96
133
|
bucket.startedTimers = 0;
|
|
97
134
|
}
|
|
135
|
+
skippedTicks = 0;
|
|
136
|
+
bufferedOutputBytesProbe = undefined;
|
|
98
137
|
},
|
|
99
138
|
};
|
package/src/components/loader.ts
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
import { type AnimationRegistration, registerAnimationCallback } from "../animation-scheduler";
|
|
2
|
+
import { isRemoteTerminalSession, isUnderTerminalMultiplexer } from "../terminal-capabilities";
|
|
2
3
|
import type { TUI } from "../tui";
|
|
3
4
|
import { sliceByColumn, visibleWidth } from "../utils";
|
|
4
5
|
import { Text } from "./text";
|
|
5
6
|
|
|
6
7
|
const SPINNER_ADVANCE_MS = 80;
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Compatibility options for existing loader call sites.
|
|
11
|
+
*
|
|
12
|
+
* @deprecated `timeDependentColor` preserves the historical smooth-animation
|
|
13
|
+
* contract: direct local terminals reevaluate colorizers at 60 fps, while
|
|
14
|
+
* remote or multiplexed terminals stay on the shared 80 ms cadence to avoid
|
|
15
|
+
* output churn. The field remains accepted for downstream callers while they
|
|
16
|
+
* migrate to an explicit animation policy.
|
|
17
|
+
*/
|
|
8
18
|
export interface LoaderOptions {
|
|
9
19
|
timeDependentColor?: boolean;
|
|
10
20
|
}
|
|
11
21
|
|
|
22
|
+
const SMOOTH_ANIMATION_MS = 16;
|
|
23
|
+
|
|
24
|
+
function resolveAnimationCadence(options: LoaderOptions): 16 | 80 {
|
|
25
|
+
if (options.timeDependentColor !== true) return SPINNER_ADVANCE_MS;
|
|
26
|
+
return isRemoteTerminalSession() || isUnderTerminalMultiplexer() ? SPINNER_ADVANCE_MS : SMOOTH_ANIMATION_MS;
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/** Test-only performance counters for advisory baseline tests. */
|
|
13
30
|
export const __loaderPerfCounters = {
|
|
14
31
|
liveIntervals: 0,
|
|
15
32
|
startedIntervals: 0,
|
|
33
|
+
callbackInvocations: 0,
|
|
34
|
+
renderRequests: 0,
|
|
16
35
|
reset(): void {
|
|
17
36
|
this.liveIntervals = 0;
|
|
18
37
|
this.startedIntervals = 0;
|
|
38
|
+
this.callbackInvocations = 0;
|
|
39
|
+
this.renderRequests = 0;
|
|
19
40
|
},
|
|
20
41
|
};
|
|
21
42
|
|
|
@@ -26,7 +47,6 @@ export class Loader extends Text {
|
|
|
26
47
|
#ui: TUI | null = null;
|
|
27
48
|
#lastSpinnerTick = 0;
|
|
28
49
|
#lastDisplayed?: string;
|
|
29
|
-
#timeDependentColor: boolean;
|
|
30
50
|
|
|
31
51
|
constructor(
|
|
32
52
|
ui: TUI,
|
|
@@ -34,11 +54,10 @@ export class Loader extends Text {
|
|
|
34
54
|
private messageColorFn: (str: string) => string,
|
|
35
55
|
private message: string = "Loading...",
|
|
36
56
|
spinnerFrames?: string[],
|
|
37
|
-
options: LoaderOptions = {},
|
|
57
|
+
private options: LoaderOptions = {},
|
|
38
58
|
) {
|
|
39
59
|
super("", 1, 0);
|
|
40
60
|
this.#ui = ui;
|
|
41
|
-
this.#timeDependentColor = options.timeDependentColor ?? false;
|
|
42
61
|
if (spinnerFrames && spinnerFrames.length > 0) {
|
|
43
62
|
this.#frames = spinnerFrames;
|
|
44
63
|
}
|
|
@@ -62,16 +81,14 @@ export class Loader extends Text {
|
|
|
62
81
|
this.#updateDisplay();
|
|
63
82
|
__loaderPerfCounters.liveIntervals += 1;
|
|
64
83
|
__loaderPerfCounters.startedIntervals += 1;
|
|
65
|
-
this.#animation = registerAnimationCallback(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.#timeDependentColor ? 16 : 80,
|
|
74
|
-
);
|
|
84
|
+
this.#animation = registerAnimationCallback(now => {
|
|
85
|
+
__loaderPerfCounters.callbackInvocations += 1;
|
|
86
|
+
if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
|
|
87
|
+
this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
|
|
88
|
+
this.#lastSpinnerTick = now;
|
|
89
|
+
}
|
|
90
|
+
this.#updateDisplay();
|
|
91
|
+
}, resolveAnimationCadence(this.options));
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
stop() {
|
|
@@ -97,6 +114,7 @@ export class Loader extends Text {
|
|
|
97
114
|
if (next === this.#lastDisplayed) return;
|
|
98
115
|
this.#lastDisplayed = next;
|
|
99
116
|
this.setText(next);
|
|
117
|
+
__loaderPerfCounters.renderRequests += 1;
|
|
100
118
|
this.#ui?.requestRender(false, "loader");
|
|
101
119
|
}
|
|
102
120
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getKeybindings } from "../keybindings";
|
|
2
|
+
import { extractPrintableText, matchesKey } from "../keys";
|
|
2
3
|
import type { Component } from "../tui";
|
|
3
|
-
import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
|
+
import { Ellipsis, getSegmenter, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
5
|
|
|
5
6
|
export interface SettingItem {
|
|
6
7
|
/** Unique identifier for this setting */
|
|
@@ -26,7 +27,9 @@ export interface SettingsListTheme {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export class SettingsList implements Component {
|
|
30
|
+
#allItems: SettingItem[];
|
|
29
31
|
#items: SettingItem[];
|
|
32
|
+
#searchQuery = "";
|
|
30
33
|
#theme: SettingsListTheme;
|
|
31
34
|
#selectedIndex = 0;
|
|
32
35
|
#maxVisible: number;
|
|
@@ -48,6 +51,7 @@ export class SettingsList implements Component {
|
|
|
48
51
|
onSelectionChange?: (item: SettingItem | undefined) => void,
|
|
49
52
|
descriptionRows = 0,
|
|
50
53
|
) {
|
|
54
|
+
this.#allItems = items;
|
|
51
55
|
this.#items = items;
|
|
52
56
|
this.#maxVisible = maxVisible;
|
|
53
57
|
this.#theme = theme;
|
|
@@ -68,7 +72,7 @@ export class SettingsList implements Component {
|
|
|
68
72
|
|
|
69
73
|
/** Update an item's currentValue */
|
|
70
74
|
updateValue(id: string, newValue: string): void {
|
|
71
|
-
const item = this.#
|
|
75
|
+
const item = this.#allItems.find(i => i.id === id);
|
|
72
76
|
if (item) {
|
|
73
77
|
item.currentValue = newValue;
|
|
74
78
|
}
|
|
@@ -82,11 +86,24 @@ export class SettingsList implements Component {
|
|
|
82
86
|
* restored index against the new list on the way out.
|
|
83
87
|
*/
|
|
84
88
|
setItems(items: SettingItem[]): void {
|
|
85
|
-
this.#
|
|
89
|
+
this.#allItems = items;
|
|
90
|
+
this.#items = this.#filterItems();
|
|
86
91
|
this.#clampSelectedIndex();
|
|
87
92
|
this.#notifySelectionChange();
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
#filterItems(): SettingItem[] {
|
|
96
|
+
const query = this.#searchQuery.toLocaleLowerCase();
|
|
97
|
+
return query ? this.#allItems.filter(item => item.label.toLocaleLowerCase().includes(query)) : this.#allItems;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#setSearchQuery(query: string): void {
|
|
101
|
+
this.#searchQuery = query.normalize("NFC");
|
|
102
|
+
this.#items = this.#filterItems();
|
|
103
|
+
this.#selectedIndex = 0;
|
|
104
|
+
this.#notifySelectionChange();
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
invalidate(): void {
|
|
91
108
|
this.#submenuComponent?.invalidate?.();
|
|
92
109
|
}
|
|
@@ -106,9 +123,17 @@ export class SettingsList implements Component {
|
|
|
106
123
|
|
|
107
124
|
#renderMainList(width: number): string[] {
|
|
108
125
|
const lines: string[] = [];
|
|
126
|
+
if (this.#searchQuery) {
|
|
127
|
+
lines.push(this.#theme.hint(truncateToWidth(` Search: ${this.#searchQuery}`, width)));
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
109
130
|
|
|
110
131
|
if (this.#items.length === 0) {
|
|
111
|
-
lines.push(this.#theme.hint(" No settings available"));
|
|
132
|
+
lines.push(this.#theme.hint(this.#searchQuery ? " No matching settings" : " No settings available"));
|
|
133
|
+
if (this.#searchQuery) {
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push(this.#theme.hint(truncateToWidth(" Type to search · Backspace to edit · Esc to clear", width)));
|
|
136
|
+
}
|
|
112
137
|
return lines;
|
|
113
138
|
}
|
|
114
139
|
|
|
@@ -181,7 +206,10 @@ export class SettingsList implements Component {
|
|
|
181
206
|
|
|
182
207
|
// Add hint
|
|
183
208
|
lines.push("");
|
|
184
|
-
|
|
209
|
+
const hint = this.#searchQuery
|
|
210
|
+
? " Type to search · Enter to change · Backspace to edit · Esc to clear"
|
|
211
|
+
: " Type to search · Enter/Space to change · Esc to cancel";
|
|
212
|
+
lines.push(truncateToWidth(this.#theme.hint(hint), width));
|
|
185
213
|
|
|
186
214
|
return lines;
|
|
187
215
|
}
|
|
@@ -196,24 +224,44 @@ export class SettingsList implements Component {
|
|
|
196
224
|
|
|
197
225
|
// Main list input handling
|
|
198
226
|
const kb = getKeybindings();
|
|
199
|
-
if (this.#items.length
|
|
200
|
-
if (kb.matches(data, "tui.select.cancel")) {
|
|
201
|
-
this.#onCancel();
|
|
202
|
-
}
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (kb.matches(data, "tui.select.up")) {
|
|
227
|
+
if (this.#items.length > 0 && kb.matches(data, "tui.select.up")) {
|
|
207
228
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
|
|
208
229
|
this.#notifySelectionChange();
|
|
209
|
-
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (this.#items.length > 0 && kb.matches(data, "tui.select.down")) {
|
|
210
233
|
this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
|
|
211
234
|
this.#notifySelectionChange();
|
|
212
|
-
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
this.#items.length > 0 &&
|
|
239
|
+
(kb.matches(data, "tui.select.confirm") || data === "\n" || (data === " " && !this.#searchQuery))
|
|
240
|
+
) {
|
|
213
241
|
this.#activateItem();
|
|
214
|
-
|
|
215
|
-
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
245
|
+
if (this.#searchQuery) {
|
|
246
|
+
this.#setSearchQuery("");
|
|
247
|
+
} else {
|
|
248
|
+
this.#onCancel();
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
216
251
|
}
|
|
252
|
+
if (this.#searchQuery && matchesKey(data, "backspace")) {
|
|
253
|
+
const graphemes = [...getSegmenter().segment(this.#searchQuery)];
|
|
254
|
+
this.#setSearchQuery(
|
|
255
|
+
graphemes
|
|
256
|
+
.slice(0, -1)
|
|
257
|
+
.map(part => part.segment)
|
|
258
|
+
.join(""),
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const printableText = extractPrintableText(data);
|
|
264
|
+
if (printableText) this.#setSearchQuery(this.#searchQuery + printableText);
|
|
217
265
|
}
|
|
218
266
|
|
|
219
267
|
#activateItem(): void {
|
|
@@ -60,6 +60,11 @@ function multiplexerEnvEnabled(value: string | undefined): boolean {
|
|
|
60
60
|
return normalized !== undefined && normalized.length > 0 && !MULTIPLEXER_DISABLED_ENV_VALUES.has(normalized);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Returns whether stdout crosses an SSH transport, where animation bytes can outpace the link. */
|
|
64
|
+
export function isRemoteTerminalSession(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
65
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
/**
|
|
64
69
|
* Returns whether the process runs under a terminal multiplexer (tmux, GNU
|
|
65
70
|
* screen, or zellij). Recognizes the same host markers as the renderer's
|
package/src/terminal.ts
CHANGED
|
@@ -263,6 +263,8 @@ function unsubscribeFromStdinErrors(subscriber: (err: Error) => void): void {
|
|
|
263
263
|
stdinErrorSubscribers.delete(subscriber);
|
|
264
264
|
if (stdinErrorSubscribers.size === 0) process.stdin.removeListener("error", dispatchStdinError);
|
|
265
265
|
}
|
|
266
|
+
type Osc11QuerySource = "startup" | "poll" | "mode2031";
|
|
267
|
+
type Osc11QueuedSource = Exclude<Osc11QuerySource, "startup">;
|
|
266
268
|
|
|
267
269
|
/**
|
|
268
270
|
* Real terminal using process.stdin/stdout
|
|
@@ -287,7 +289,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
287
289
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
288
290
|
#appearance: TerminalAppearance | undefined;
|
|
289
291
|
#osc11Pending = false;
|
|
290
|
-
#
|
|
292
|
+
#osc11QueuedSource?: Osc11QueuedSource;
|
|
291
293
|
#osc11ResponseBuffer = "";
|
|
292
294
|
#privateCsiResponseBuffer = "";
|
|
293
295
|
#pendingDa1Sentinels = 0;
|
|
@@ -409,7 +411,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
409
411
|
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
410
412
|
// the terminal does not support OSC 11. This avoids indefinite hangs.
|
|
411
413
|
// Technique used by Neovim, bat, fish, and terminal-colorsaurus.
|
|
412
|
-
this.#queryBackgroundColor();
|
|
414
|
+
this.#queryBackgroundColor("startup");
|
|
413
415
|
|
|
414
416
|
// Subscribe to Mode 2031 appearance change notifications.
|
|
415
417
|
// When the terminal reports a change, we re-query OSC 11 to get the
|
|
@@ -571,16 +573,22 @@ export class ProcessTerminal implements Terminal {
|
|
|
571
573
|
// already succeeded. Other terminal probes should never see these replies.
|
|
572
574
|
if (da1ResponsePattern.test(sequence) && this.#pendingDa1Sentinels > 0) {
|
|
573
575
|
this.#pendingDa1Sentinels--;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
576
|
+
const negativeEvidence = this.#osc11Pending;
|
|
577
|
+
const queuedSource = this.#osc11QueuedSource;
|
|
578
|
+
this.#osc11QueuedSource = undefined;
|
|
579
|
+
if (negativeEvidence) {
|
|
580
|
+
// DA1 arrived before OSC 11: this cycle proved OSC 11 unsupported.
|
|
581
|
+
// Stop futile polling, but retain one stronger Mode 2031 push request.
|
|
578
582
|
this.#osc11Pending = false;
|
|
579
583
|
this.#osc11ResponseBuffer = "";
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
+
this.#clearOsc11QueryWatchdog();
|
|
585
|
+
this.#stopOsc11Poll();
|
|
586
|
+
if (queuedSource === "mode2031" && !this.#dead) {
|
|
587
|
+
this.#startOsc11Query();
|
|
588
|
+
}
|
|
589
|
+
} else if (queuedSource && !this.#dead) {
|
|
590
|
+
// A positive OSC reply arrived first. The delayed sentinel only
|
|
591
|
+
// closes that successful cycle, so preserve its single follow-up.
|
|
584
592
|
this.#startOsc11Query();
|
|
585
593
|
}
|
|
586
594
|
return;
|
|
@@ -600,8 +608,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
600
608
|
const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
|
|
601
609
|
if (osc11Match) {
|
|
602
610
|
const [, rHex, gHex, bHex] = osc11Match;
|
|
603
|
-
this.#osc11Pending = false;
|
|
604
611
|
this.#osc11ResponseBuffer = "";
|
|
612
|
+
if (!this.#osc11Pending) return;
|
|
613
|
+
this.#osc11Pending = false;
|
|
605
614
|
this.#clearOsc11QueryWatchdog();
|
|
606
615
|
this.#handleOsc11Response(rHex!, gHex!, bHex!);
|
|
607
616
|
return;
|
|
@@ -628,7 +637,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
628
637
|
if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
|
|
629
638
|
this.#mode2031DebounceTimer = setTimeout(() => {
|
|
630
639
|
this.#mode2031DebounceTimer = undefined;
|
|
631
|
-
this.#queryBackgroundColor();
|
|
640
|
+
this.#queryBackgroundColor("mode2031");
|
|
632
641
|
}, 100);
|
|
633
642
|
return;
|
|
634
643
|
}
|
|
@@ -662,14 +671,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
662
671
|
* DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
|
|
663
672
|
* the terminal does not support OSC 11.
|
|
664
673
|
*/
|
|
665
|
-
#queryBackgroundColor(): void {
|
|
674
|
+
#queryBackgroundColor(source: Osc11QuerySource): void {
|
|
666
675
|
if (this.#dead) return;
|
|
667
|
-
// Queue if an OSC 11 query is in flight or its DA1 sentinel
|
|
668
|
-
// consumed
|
|
669
|
-
// increment the sentinel counter, and the old DA1 arrival would then
|
|
670
|
-
// prematurely clear the new query's pending state.
|
|
676
|
+
// Queue if an OSC 11 query is in flight or its DA1 sentinel has not yet
|
|
677
|
+
// been consumed. Mode 2031 push evidence outranks a periodic poll.
|
|
671
678
|
if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
|
|
672
|
-
this.#
|
|
679
|
+
if (source === "mode2031" || (source === "poll" && this.#osc11QueuedSource === undefined)) {
|
|
680
|
+
this.#osc11QueuedSource = source;
|
|
681
|
+
}
|
|
673
682
|
return;
|
|
674
683
|
}
|
|
675
684
|
this.#startOsc11Query();
|
|
@@ -698,11 +707,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
698
707
|
this.#osc11QueryWatchdog = undefined;
|
|
699
708
|
if (this.#dead) return;
|
|
700
709
|
if (!this.#osc11Pending && this.#pendingDa1Sentinels === 0) return;
|
|
710
|
+
const queuedSource = this.#osc11QueuedSource;
|
|
711
|
+
this.#osc11QueuedSource = undefined;
|
|
701
712
|
this.#osc11Pending = false;
|
|
702
713
|
this.#osc11ResponseBuffer = "";
|
|
703
714
|
this.#pendingDa1Sentinels = 0;
|
|
704
|
-
if (
|
|
705
|
-
this.#osc11QueryQueued = false;
|
|
715
|
+
if (queuedSource && !this.#dead) {
|
|
706
716
|
this.#startOsc11Query();
|
|
707
717
|
}
|
|
708
718
|
}, 1000);
|
|
@@ -750,7 +760,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
750
760
|
this.#stopOsc11Poll();
|
|
751
761
|
return;
|
|
752
762
|
}
|
|
753
|
-
this.#queryBackgroundColor();
|
|
763
|
+
this.#queryBackgroundColor("poll");
|
|
754
764
|
}, 2_000);
|
|
755
765
|
this.#osc11PollTimer.unref();
|
|
756
766
|
}
|
|
@@ -878,7 +888,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
878
888
|
}
|
|
879
889
|
this.#appearanceCallbacks = [];
|
|
880
890
|
this.#osc11Pending = false;
|
|
881
|
-
this.#
|
|
891
|
+
this.#osc11QueuedSource = undefined;
|
|
882
892
|
this.#osc11ResponseBuffer = "";
|
|
883
893
|
this.#privateCsiResponseBuffer = "";
|
|
884
894
|
this.#pendingDa1Sentinels = 0;
|
package/src/tui.ts
CHANGED
|
@@ -430,6 +430,28 @@ export function shouldUseViewportRepaintForHost(
|
|
|
430
430
|
return isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal);
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Viewport-repaint host gate resolved against a terminal's reported capability.
|
|
435
|
+
*
|
|
436
|
+
* `includeNativeWindows` exists so a Windows console host that cannot report
|
|
437
|
+
* `isProcessTerminal` is still recognized from platform identity. It is a
|
|
438
|
+
* fallback, so it must not outrank a terminal that has answered: a terminal
|
|
439
|
+
* reporting `false` is not a native console host, and letting win32 override it
|
|
440
|
+
* gives every non-process terminal on Windows — embedders, pipes, and the
|
|
441
|
+
* render regression suite — viewport-repaint semantics. Those hosts then never
|
|
442
|
+
* replay durable history, so contracted rows survive as duplicates.
|
|
443
|
+
*/
|
|
444
|
+
export function shouldUseViewportRepaintForTerminal(
|
|
445
|
+
isProcessTerminal: boolean | undefined,
|
|
446
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
447
|
+
platform: NodeJS.Platform = process.platform,
|
|
448
|
+
): boolean {
|
|
449
|
+
return shouldUseViewportRepaintForHost(env, platform, {
|
|
450
|
+
includeNativeWindows: isProcessTerminal !== false,
|
|
451
|
+
includeProcessTerminal: isProcessTerminal === true,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
433
455
|
/**
|
|
434
456
|
* Options for overlay positioning and sizing.
|
|
435
457
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
@@ -903,6 +925,8 @@ export class TUI extends Container {
|
|
|
903
925
|
// showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
|
|
904
926
|
#imeCursorActive = false;
|
|
905
927
|
#clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK");
|
|
928
|
+
#synchronizedOutputBegin = "";
|
|
929
|
+
#synchronizedOutputEnd = "";
|
|
906
930
|
|
|
907
931
|
// Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
|
|
908
932
|
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
|
|
@@ -997,6 +1021,9 @@ export class TUI extends Container {
|
|
|
997
1021
|
this.terminal = terminal;
|
|
998
1022
|
this.#legacyMultiplexerFullRender =
|
|
999
1023
|
isMultiplexerSession(Bun.env) && envIsEnabled(Bun.env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
|
|
1024
|
+
const synchronizedOutputEnabled = $flag("GJC_TUI_SYNCHRONIZED_OUTPUT", true);
|
|
1025
|
+
this.#synchronizedOutputBegin = synchronizedOutputEnabled ? "\x1b[?2026h" : "";
|
|
1026
|
+
this.#synchronizedOutputEnd = synchronizedOutputEnabled ? "\x1b[?2026l" : "";
|
|
1000
1027
|
if (showHardwareCursor !== undefined) {
|
|
1001
1028
|
this.#showHardwareCursor = showHardwareCursor;
|
|
1002
1029
|
}
|
|
@@ -1671,6 +1698,10 @@ export class TUI extends Container {
|
|
|
1671
1698
|
return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
|
|
1672
1699
|
}
|
|
1673
1700
|
|
|
1701
|
+
#frameSynchronizedOutput(payload: string): string {
|
|
1702
|
+
return `${this.#synchronizedOutputBegin}${payload}${this.#synchronizedOutputEnd}`;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1674
1705
|
#hideCursor(): boolean {
|
|
1675
1706
|
return this.#guardTerminalOperation(() => this.terminal.hideCursor());
|
|
1676
1707
|
}
|
|
@@ -1939,6 +1970,11 @@ export class TUI extends Container {
|
|
|
1939
1970
|
this.#forcedRenderQueued = false;
|
|
1940
1971
|
}
|
|
1941
1972
|
|
|
1973
|
+
/** Host gate for viewport-repaint decisions, resolved against this terminal. */
|
|
1974
|
+
#viewportRepaintHost(): boolean {
|
|
1975
|
+
return shouldUseViewportRepaintForTerminal(this.terminal.isProcessTerminal);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1942
1978
|
/**
|
|
1943
1979
|
* Viewport-repaint-aware resize render request.
|
|
1944
1980
|
*
|
|
@@ -1962,13 +1998,7 @@ export class TUI extends Container {
|
|
|
1962
1998
|
this.#lastObservedWidth = observedWidth;
|
|
1963
1999
|
const heightChanged = this.#previousHeight !== this.terminal.rows;
|
|
1964
2000
|
if (widthChanged) this.#scheduleWidthSettleRedraw();
|
|
1965
|
-
this.requestRender(
|
|
1966
|
-
heightChanged &&
|
|
1967
|
-
!shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
1968
|
-
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
1969
|
-
}),
|
|
1970
|
-
"resize",
|
|
1971
|
-
);
|
|
2001
|
+
this.requestRender(heightChanged && !this.#viewportRepaintHost(), "resize");
|
|
1972
2002
|
}
|
|
1973
2003
|
|
|
1974
2004
|
/**
|
|
@@ -3135,7 +3165,7 @@ export class TUI extends Container {
|
|
|
3135
3165
|
{ top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
|
|
3136
3166
|
]
|
|
3137
3167
|
: [{ top: nextViewportTop, bottom: nextViewportTop + height }];
|
|
3138
|
-
let buffer =
|
|
3168
|
+
let buffer = deletePlan.output;
|
|
3139
3169
|
buffer += "\x1b[H";
|
|
3140
3170
|
const committedTranscriptRows: Array<number | null> = [];
|
|
3141
3171
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
@@ -3175,7 +3205,7 @@ export class TUI extends Container {
|
|
|
3175
3205
|
cursorToRow = cursor.toRow;
|
|
3176
3206
|
}
|
|
3177
3207
|
buffer += cursorSeq;
|
|
3178
|
-
buffer
|
|
3208
|
+
buffer = this.#frameSynchronizedOutput(buffer);
|
|
3179
3209
|
let contentWritten = false;
|
|
3180
3210
|
const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
|
|
3181
3211
|
contentWritten = true;
|
|
@@ -3564,10 +3594,7 @@ export class TUI extends Container {
|
|
|
3564
3594
|
return;
|
|
3565
3595
|
}
|
|
3566
3596
|
// Helper to clear scrollback and viewport and render all new lines
|
|
3567
|
-
const shouldPreserveScrollbackOnFullClear =
|
|
3568
|
-
shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
3569
|
-
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
3570
|
-
}) || this.#legacyMultiplexerFullRender;
|
|
3597
|
+
const shouldPreserveScrollbackOnFullClear = this.#viewportRepaintHost() || this.#legacyMultiplexerFullRender;
|
|
3571
3598
|
let viewportRepaint: (
|
|
3572
3599
|
reason: string,
|
|
3573
3600
|
targetViewportTopOrAllowPastLiveBottom?: number | boolean,
|
|
@@ -3591,7 +3618,7 @@ export class TUI extends Container {
|
|
|
3591
3618
|
[],
|
|
3592
3619
|
clear,
|
|
3593
3620
|
);
|
|
3594
|
-
let buffer =
|
|
3621
|
+
let buffer = deletePlan.output;
|
|
3595
3622
|
// Skip clearing scrollback (3J) in hosts where clear/replay can snap the
|
|
3596
3623
|
// native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
|
|
3597
3624
|
// unless the caller explicitly needs history erased (the settled width
|
|
@@ -3609,7 +3636,7 @@ export class TUI extends Container {
|
|
|
3609
3636
|
const cursorRow = Math.max(0, newLines.length - 1);
|
|
3610
3637
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
|
|
3611
3638
|
buffer += seq;
|
|
3612
|
-
buffer
|
|
3639
|
+
buffer = this.#frameSynchronizedOutput(buffer);
|
|
3613
3640
|
if (
|
|
3614
3641
|
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
3615
3642
|
this.#cursorRow = cursorRow;
|
|
@@ -3763,9 +3790,7 @@ export class TUI extends Container {
|
|
|
3763
3790
|
fullRender(true, "width settled", true);
|
|
3764
3791
|
return;
|
|
3765
3792
|
}
|
|
3766
|
-
const useViewportRepaintPath =
|
|
3767
|
-
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
3768
|
-
});
|
|
3793
|
+
const useViewportRepaintPath = this.#viewportRepaintHost();
|
|
3769
3794
|
const widthReflowRequired =
|
|
3770
3795
|
this.#previousWidth > 0 &&
|
|
3771
3796
|
rawLines.some(
|
|
@@ -3811,7 +3836,9 @@ export class TUI extends Container {
|
|
|
3811
3836
|
.slice(0, this.#restartDurableLineCount)
|
|
3812
3837
|
.every((line, index) => line === this.#restartDurableRawLines[index]);
|
|
3813
3838
|
if (restartAppendProven && rawLines.length > this.#restartDurableLineCount) {
|
|
3814
|
-
const appendBuffer =
|
|
3839
|
+
const appendBuffer = this.#frameSynchronizedOutput(
|
|
3840
|
+
newLines.slice(this.#restartDurableLineCount).join("\r\n"),
|
|
3841
|
+
);
|
|
3815
3842
|
if (!this.#writeTerminal(appendBuffer)) return;
|
|
3816
3843
|
// The append already reached native scrollback. Advance both the live
|
|
3817
3844
|
// frontier and the retained restart baseline before the viewport write:
|
|
@@ -4057,7 +4084,7 @@ export class TUI extends Container {
|
|
|
4057
4084
|
const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
|
|
4058
4085
|
{ top: firstChanged, bottom: lastChanged + 1 },
|
|
4059
4086
|
]);
|
|
4060
|
-
let buffer =
|
|
4087
|
+
let buffer = deletePlan.output;
|
|
4061
4088
|
// Move to end of new content (clamp to 0 for empty content)
|
|
4062
4089
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
4063
4090
|
const lineDiff = computeLineDiff(targetRow);
|
|
@@ -4085,7 +4112,7 @@ export class TUI extends Container {
|
|
|
4085
4112
|
}
|
|
4086
4113
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
|
|
4087
4114
|
buffer += seq;
|
|
4088
|
-
buffer
|
|
4115
|
+
buffer = this.#frameSynchronizedOutput(buffer);
|
|
4089
4116
|
if (
|
|
4090
4117
|
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
4091
4118
|
this.#cursorRow = targetRow;
|
|
@@ -4226,7 +4253,7 @@ export class TUI extends Container {
|
|
|
4226
4253
|
const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
|
|
4227
4254
|
{ top: firstChanged, bottom: lastChanged + 1 },
|
|
4228
4255
|
]);
|
|
4229
|
-
let buffer =
|
|
4256
|
+
let buffer = deletePlan.output;
|
|
4230
4257
|
const prevViewportBottom = prevViewportTop + height - 1;
|
|
4231
4258
|
const nativeScrollbackAdmission =
|
|
4232
4259
|
appendedLines &&
|
|
@@ -4341,7 +4368,7 @@ export class TUI extends Container {
|
|
|
4341
4368
|
|
|
4342
4369
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
|
|
4343
4370
|
buffer += seq;
|
|
4344
|
-
buffer
|
|
4371
|
+
buffer = this.#frameSynchronizedOutput(buffer);
|
|
4345
4372
|
|
|
4346
4373
|
if ($pickflag("GJC_TUI_DEBUG", "PI_TUI_DEBUG")) {
|
|
4347
4374
|
const debugDir = "/tmp/tui";
|
|
@@ -4490,7 +4517,7 @@ export class TUI extends Container {
|
|
|
4490
4517
|
// DECSC/DECRC keep the hardware cursor stable; the dedicated
|
|
4491
4518
|
// synchronized block prevents visible tearing while the overlay
|
|
4492
4519
|
// area is cleared and redrawn.
|
|
4493
|
-
const overlayBuffer = `\
|
|
4520
|
+
const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
|
|
4494
4521
|
// Overlay delivery is outside shared transcript ownership. The
|
|
4495
4522
|
// shared write has already committed even when this exempt write
|
|
4496
4523
|
// fails, so do not make callers retry the shared bytes.
|