@oh-my-pi/pi-tui 15.8.0 → 15.8.3
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 +32 -0
- package/README.md +1 -1
- package/dist/types/terminal-capabilities.d.ts +25 -1
- package/dist/types/terminal.d.ts +26 -51
- package/dist/types/tui.d.ts +14 -5
- package/package.json +3 -3
- package/src/terminal-capabilities.ts +83 -17
- package/src/terminal.ts +26 -91
- package/src/tui.ts +259 -75
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.8.2] - 2026-06-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `PI_NO_SYNC_OUTPUT=1` to disable DEC 2026 synchronized-output wrappers for terminals whose implementation is buggy or visually worse, while keeping the renderer's autowrap guards active during paints ([#1765](https://github.com/can1357/oh-my-pi/issues/1765)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed terminal resizes that land in the same render frame as streamed output splicing a phantom blank row into native scrollback and offsetting every later row by one. A height shrink (or width change carrying an append) with content overflowing the viewport fell through to the differential emitter, whose scroll math is anchored to the pre-resize viewport top and hardware-cursor row — both invalidated by the terminal's own resize reflow. Geometry-changed frames now rebuild native history when the viewport is at (or possibly at) the bottom, and defer non-destructively for a reader confirmed scrolled into history.
|
|
14
|
+
- Fixed Ghostty/kitty/Alacritty-style ED3-risk terminals freezing the prompt after a deferred shrink; focused keyboard input now uses the same explicit user-input viewport opt-in as autocomplete and can repaint immediately instead of waiting for a resize.
|
|
15
|
+
- Deferred eager live scrollback rebuilds under WSL fronted by Windows Terminal (`WT_SESSION` present in a Linux environment) so foreground streaming no longer emits ED3 (`CSI 3 J`) and yanks a reader scrolled into Windows Terminal's host scrollback; deferred rewrites still reconcile at the next prompt-submit checkpoint ([#1610](https://github.com/can1357/oh-my-pi/issues/1610)).
|
|
16
|
+
- Fixed tmux (and screen/zellij) pane history gaining a complete duplicate copy of the transcript every time a deferred offscreen edit was followed by another render. Multiplexer panes never receive a destructive scrollback clear, so the dirty-scrollback rebuild path only appended the full transcript on top of preserved pane history — repeatedly. Live frames inside multiplexers now keep repainting the viewport and leave history reconciliation to explicit checkpoints, which also removes the O(transcript) write amplification per frame.
|
|
17
|
+
- Fixed tmux pane viewports corrupting and pane history duplicating when a resize coincides with rendering: a resize racing a streamed append reached the stale-anchor diff emitters (phantom rows in the pane), a forced render racing a resize replayed the whole transcript into preserved pane history, and the prompt-submit checkpoint did the same after any deferred offscreen edit. Geometry-changed frames inside multiplexers now repaint the viewport in place, and forced-render geometry replays plus checkpoint replays are disabled there — tmux reflows its own pane grid and its history cannot be cleared, only duplicated.
|
|
18
|
+
- Fixed terminal resize events whose dimensions net out unchanged by render time (rapid SIGWINCH round trips during a window drag, coalesced into one 16ms frame) being invisible to the renderer. The terminal reflows its buffer on every resize event — rows move between the viewport and scrollback and can be evicted at the scrollback cap — so diffing against the pre-resize screen splices blank phantom rows into the viewport. The renderer now tracks the resize event itself, not just the dimension delta, and routes such frames through the geometry-change repaint/rebuild paths.
|
|
19
|
+
- Fixed Termux terminal resizes (screen rotation or software-keyboard toggles) displacing or hiding output after the viewport height changed. Content-bearing resizes were routed to the differential emitter, whose scroll math is anchored to the pre-resize viewport, so appended rows landed too low; pure height changes were treated as no-ops, exposing blank rows that later appends could fill without growing native scrollback. Termux resizes now repaint or rebuild at the new geometry like every other non-multiplexer terminal.
|
|
20
|
+
- Fixed the turn-end teardown frame freezing on ED3-risk terminals (Ghostty/kitty/Alacritty/iTerm2): disabling eager scrollback rebuild now takes effect only after the in-flight frame is classified, so the loader/status removal still paints instead of deferring and leaving a stale spinner until the next keystroke.
|
|
21
|
+
- Fixed non-WT ConPTY terminals on Windows (Tabby, Hyper, VS Code, conhost) clearing scrollback and yanking the viewport to the top whenever streaming output or a prompt-submit rebuild arrived while the user was scrolled up. The kernel32 viewport probe describes the ConPTY pseudo-console buffer — which is pinned to the visible grid, invisible to host-UI scrollback — so it reported "at bottom" no matter where the user had scrolled, and the [#1635](https://github.com/can1357/oh-my-pi/issues/1635) fix only distrusted it under `WT_SESSION`, which Tabby and other ConPTY hosts never set. The probe is now removed entirely: every Windows host is treated as viewport-unobservable, live mutations defer destructive rebuilds (no `\x1b[3J`, no viewport movement), and native scrollback reconciles at the prompt-submit checkpoint where the Enter keystroke has already pinned the host viewport to the bottom ([#1746](https://github.com/can1357/oh-my-pi/issues/1746)).
|
|
22
|
+
- Fixed emoji-presentation symbols (a default-text symbol followed by variation-selector-16 `U+FE0F`, e.g. `⚠️`, `ℹ️`, `❤️`, keycaps) measuring as 1 cell instead of 2 in the native width engine on macOS. The native scanner now keeps `UnicodeWidthStr` as the source of truth for multi-codepoint graphemes and applies only the local macOS Hangul Compatibility Jamo character-width delta, preserving VS16/keycap sequence widths without reintroducing jamo cursor drift.
|
|
23
|
+
- Deferred eager live scrollback rebuilds on macOS Terminal.app and iTerm2 so assistant/tool streaming no longer emits ED3 (`CSI 3 J`) while their native viewport position is unobservable, preserving readers scrolled into terminal history ([#1300](https://github.com/can1357/oh-my-pi/issues/1300)).
|
|
24
|
+
- Fixed width-shrink reflow leaving old-width rows in native history so later appends no longer undercount scrollback growth or duplicate wrapped content.
|
|
25
|
+
- Fixed hiding overlays after terminal reflow so stale dialog rows are scrubbed from native scrollback on non-multiplexer terminals.
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
- Removed `shouldTrustNativeViewportProbe` and `ProcessTerminal`'s kernel32 `GetConsoleScreenBufferInfo` viewport probe. No Windows environment can answer "is the user's viewport at the bottom" truthfully — under ConPTY (every modern host) the pseudo-console buffer is pinned to the visible grid so the probe always read "at bottom", and under legacy conhost the window tracks the output cursor rather than the buffer tail so it always read "scrolled up" — so the probe and its trust gate are gone; `ProcessTerminal` no longer implements the optional `Terminal.isNativeViewportAtBottom`.
|
|
30
|
+
|
|
31
|
+
## [15.8.1] - 2026-06-02
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Deferred eager live scrollback rebuilds on VTE terminals so GNOME-style Linux terminals do not flash or erase readable scrollback during streaming ([#1719](https://github.com/can1357/oh-my-pi/issues/1719)).
|
|
36
|
+
|
|
5
37
|
## [15.8.0] - 2026-06-02
|
|
6
38
|
|
|
7
39
|
### Fixed
|
package/README.md
CHANGED
|
@@ -513,7 +513,7 @@ The TUI uses three rendering strategies:
|
|
|
513
513
|
2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
|
|
514
514
|
3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
|
|
515
515
|
|
|
516
|
-
All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
|
|
516
|
+
All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering unless `PI_NO_SYNC_OUTPUT=1` is set. The opt-out removes only the DEC 2026 wrapper; paint writes still guard terminal autowrap to avoid pending-wrap cursor artifacts.
|
|
517
517
|
|
|
518
518
|
## Terminal Interface
|
|
519
519
|
|
|
@@ -16,7 +16,8 @@ export declare class TerminalInfo {
|
|
|
16
16
|
readonly trueColor: boolean;
|
|
17
17
|
readonly hyperlinks: boolean;
|
|
18
18
|
readonly notifyProtocol: NotifyProtocol;
|
|
19
|
-
|
|
19
|
+
readonly eagerEraseScrollbackRisk: boolean;
|
|
20
|
+
constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol, eagerEraseScrollbackRisk?: boolean);
|
|
20
21
|
isImageLine(line: string): boolean;
|
|
21
22
|
formatNotification(message: string): string;
|
|
22
23
|
sendNotification(message: string): void;
|
|
@@ -28,6 +29,29 @@ export declare function isNotificationSuppressed(): boolean;
|
|
|
28
29
|
* Windows Terminal introduced SIXEL support in preview 1.22.
|
|
29
30
|
*/
|
|
30
31
|
export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether eager live-frame native scrollback rebuilds are unsafe when the
|
|
34
|
+
* terminal viewport position is unobservable.
|
|
35
|
+
*
|
|
36
|
+
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
|
|
37
|
+
* terminals below, ED3 can disturb a reader parked in native scrollback during
|
|
38
|
+
* streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
|
|
39
|
+
* active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
|
|
40
|
+
* iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
|
|
41
|
+
* invalidates a reader's scrollback position during live streaming.
|
|
42
|
+
*
|
|
43
|
+
* Windows Terminal erases its host scrollback on ED3 and repositions the
|
|
44
|
+
* viewport against the shortened buffer, so a scrolled-up reader is yanked.
|
|
45
|
+
* Native win32 is excluded here because the renderer guards it with dedicated
|
|
46
|
+
* platform checks (the viewport position is never observable on Windows — see
|
|
47
|
+
* `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
|
|
48
|
+
* platform means the outer host is Windows Terminal fronting a WSL distro (WT
|
|
49
|
+
* propagates the variable into the Linux environment), where the same ED3
|
|
50
|
+
* yank applies. See #1610.
|
|
51
|
+
*
|
|
52
|
+
* Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
|
|
53
|
+
*/
|
|
54
|
+
export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
31
55
|
export declare const TERMINAL_ID: TerminalId;
|
|
32
56
|
export declare const TERMINAL: TerminalInfo;
|
|
33
57
|
/**
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -30,6 +30,32 @@ export interface Terminal {
|
|
|
30
30
|
/**
|
|
31
31
|
* Returns whether the native terminal viewport is at the scrollback tail when
|
|
32
32
|
* the host exposes that state. `undefined` means the terminal cannot report it.
|
|
33
|
+
*
|
|
34
|
+
* `ProcessTerminal` deliberately does not implement this — no real terminal
|
|
35
|
+
* can answer it truthfully:
|
|
36
|
+
*
|
|
37
|
+
* - POSIX terminals expose no scrollback-position API at all.
|
|
38
|
+
* - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
|
|
39
|
+
* Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
|
|
40
|
+
* ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
|
|
41
|
+
* pseudo-console buffer. That buffer is pinned to the visible grid —
|
|
42
|
+
* scrollback lives in the host UI, invisible to console APIs
|
|
43
|
+
* (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
|
|
44
|
+
* where the user scrolled. Trusting it let streaming-time rebuilds emit
|
|
45
|
+
* `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
|
|
46
|
+
* (Tabby and other ConPTY hosts). No env var distinguishes these hosts
|
|
47
|
+
* (Tabby sets none), so trust cannot be conditional on the environment.
|
|
48
|
+
* - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
|
|
49
|
+
* buffer, but its window follows the output cursor: a probe comparing
|
|
50
|
+
* `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
|
|
51
|
+
* following live output until all ~9001 buffer rows fill, permanently
|
|
52
|
+
* blocking checkpoint scrollback reconciliation.
|
|
53
|
+
*
|
|
54
|
+
* The renderer treats a missing implementation / `undefined` as "unknown":
|
|
55
|
+
* live mutations defer destructive rebuilds and reconcile native scrollback
|
|
56
|
+
* at explicit checkpoints (prompt submit), where the user's keystroke has
|
|
57
|
+
* already pinned the host viewport to the bottom. Only test terminals
|
|
58
|
+
* (xterm.js-backed) implement this with a real answer.
|
|
33
59
|
*/
|
|
34
60
|
isNativeViewportAtBottom?(): boolean | undefined;
|
|
35
61
|
/**
|
|
@@ -41,43 +67,6 @@ export interface Terminal {
|
|
|
41
67
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
42
68
|
get appearance(): TerminalAppearance | undefined;
|
|
43
69
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Whether the native console viewport-position probe should be consulted.
|
|
46
|
-
*
|
|
47
|
-
* Returns `true` only on native Windows that is *not* fronted by Windows
|
|
48
|
-
* Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
|
|
49
|
-
* ConPTY pseudo-console — which is always pinned to its tail — and not about
|
|
50
|
-
* the user-visible scrollback in modern hosts. Treat any such host as
|
|
51
|
-
* unreportable so the renderer falls back to the deferred-rebuild path.
|
|
52
|
-
*
|
|
53
|
-
* Pure helper for unit testing; the runtime call site reads `$env` /
|
|
54
|
-
* `process.platform`. See #1635.
|
|
55
|
-
*/
|
|
56
|
-
export declare function shouldTrustNativeViewportProbe(env?: {
|
|
57
|
-
WT_SESSION?: string | undefined;
|
|
58
|
-
}, platform?: NodeJS.Platform): boolean;
|
|
59
|
-
/**
|
|
60
|
-
* Whether eager live-frame native scrollback rebuilds are unsafe for the
|
|
61
|
-
* current POSIX terminal when its viewport position is unobservable.
|
|
62
|
-
*
|
|
63
|
-
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
|
|
64
|
-
* terminals below, ED3 can disturb a reader parked in native scrollback during
|
|
65
|
-
* streaming: kitty/ghostty/alacritty clamp the scroll offset back to the active
|
|
66
|
-
* tail when saved lines are erased, and WezTerm is the reported POSIX host for
|
|
67
|
-
* #1682. Defer only the eager streaming opt-in on these hosts; direct
|
|
68
|
-
* user-input renders and explicit checkpoint rebuilds still pass their own
|
|
69
|
-
* `allowUnknownViewportMutation` / `allowUnknownViewport` flags.
|
|
70
|
-
*
|
|
71
|
-
* Pure helper for unit testing; the runtime call site reads `$env` /
|
|
72
|
-
* `process.platform`. See #1682.
|
|
73
|
-
*/
|
|
74
|
-
export declare function terminalHasEagerEraseScrollbackRisk(env?: {
|
|
75
|
-
WEZTERM_PANE?: string | undefined;
|
|
76
|
-
KITTY_WINDOW_ID?: string | undefined;
|
|
77
|
-
GHOSTTY_RESOURCES_DIR?: string | undefined;
|
|
78
|
-
ALACRITTY_WINDOW_ID?: string | undefined;
|
|
79
|
-
TERM_PROGRAM?: string | undefined;
|
|
80
|
-
}, platform?: NodeJS.Platform): boolean;
|
|
81
70
|
/**
|
|
82
71
|
* Real terminal using process.stdin/stdout
|
|
83
72
|
*/
|
|
@@ -87,20 +76,6 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
87
76
|
get appearance(): TerminalAppearance | undefined;
|
|
88
77
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
89
78
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
90
|
-
/**
|
|
91
|
-
* Returns true when Windows' active console viewport is at the scrollback tail.
|
|
92
|
-
* POSIX terminals do not expose native scrollback position through a standard API.
|
|
93
|
-
*
|
|
94
|
-
* On native Windows running under Windows Terminal (the default modern
|
|
95
|
-
* host), the `kernel32` probe answers about the ConPTY pseudo-console — not
|
|
96
|
-
* the user-visible WT viewport — so it would always read "at bottom" while
|
|
97
|
-
* the user is scrolled up. Return `undefined` there so the renderer falls
|
|
98
|
-
* back to the POSIX-style deferred-rebuild path: streaming mutations stay
|
|
99
|
-
* non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
|
|
100
|
-
* checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
|
|
101
|
-
* is already pinned to the bottom by the editor keystroke. See #1635.
|
|
102
|
-
*/
|
|
103
|
-
isNativeViewportAtBottom(): boolean | undefined;
|
|
104
79
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
105
80
|
stop(): void;
|
|
106
81
|
write(data: string): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Terminal } from "./terminal";
|
|
2
2
|
import { visibleWidth } from "./utils";
|
|
3
3
|
type InputListenerResult = {
|
|
4
4
|
consume?: boolean;
|
|
@@ -172,10 +172,19 @@ export declare class TUI extends Container {
|
|
|
172
172
|
* duplicate-free history and is meant for windows where output above the fold
|
|
173
173
|
* is actively re-rendering — e.g. a tool whose result is still streaming and
|
|
174
174
|
* re-laying-out rows that have already scrolled into history. A terminal that
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* reports a *known*-scrolled viewport still defers, as does native Windows
|
|
176
|
+
* (the viewport is never observable there and ConPTY hosts erase host
|
|
177
|
+
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
178
|
+
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
179
|
+
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
|
|
180
|
+
* direct user-input rebuilds are unaffected.
|
|
181
|
+
*
|
|
182
|
+
* Disabling does not take effect until the next frame has been classified:
|
|
183
|
+
* the event batch that ends a foreground stream both removes its UI rows
|
|
184
|
+
* (loader/status teardown — a shrink) and clears this flag before the
|
|
185
|
+
* throttled render timer fires. If the flag dropped immediately, that
|
|
186
|
+
* teardown frame would hit the ED3-risk idle deferral and freeze on screen
|
|
187
|
+
* (stale spinner) until the next keystroke.
|
|
179
188
|
*/
|
|
180
189
|
setEagerNativeScrollbackRebuild(enabled: boolean): void;
|
|
181
190
|
setFocus(component: Component | null): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.8.
|
|
4
|
+
"version": "15.8.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.8.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.8.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.8.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.8.3",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
|
@@ -24,6 +24,7 @@ export class TerminalInfo {
|
|
|
24
24
|
public readonly trueColor: boolean,
|
|
25
25
|
public readonly hyperlinks: boolean,
|
|
26
26
|
public readonly notifyProtocol: NotifyProtocol = NotifyProtocol.Bell,
|
|
27
|
+
public readonly eagerEraseScrollbackRisk: boolean = false,
|
|
27
28
|
) {}
|
|
28
29
|
|
|
29
30
|
isImageLine(line: string): boolean {
|
|
@@ -91,6 +92,57 @@ export function isWindowsTerminalPreviewSixelSupported(
|
|
|
91
92
|
if (!version) return false;
|
|
92
93
|
return version.major > 1 || (version.major === 1 && version.minor >= 22);
|
|
93
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether eager live-frame native scrollback rebuilds are unsafe when the
|
|
98
|
+
* terminal viewport position is unobservable.
|
|
99
|
+
*
|
|
100
|
+
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
|
|
101
|
+
* terminals below, ED3 can disturb a reader parked in native scrollback during
|
|
102
|
+
* streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
|
|
103
|
+
* active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
|
|
104
|
+
* iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
|
|
105
|
+
* invalidates a reader's scrollback position during live streaming.
|
|
106
|
+
*
|
|
107
|
+
* Windows Terminal erases its host scrollback on ED3 and repositions the
|
|
108
|
+
* viewport against the shortened buffer, so a scrolled-up reader is yanked.
|
|
109
|
+
* Native win32 is excluded here because the renderer guards it with dedicated
|
|
110
|
+
* platform checks (the viewport position is never observable on Windows — see
|
|
111
|
+
* `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
|
|
112
|
+
* platform means the outer host is Windows Terminal fronting a WSL distro (WT
|
|
113
|
+
* propagates the variable into the Linux environment), where the same ED3
|
|
114
|
+
* yank applies. See #1610.
|
|
115
|
+
*
|
|
116
|
+
* Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
|
|
117
|
+
*/
|
|
118
|
+
export function detectTerminalEagerEraseScrollbackRisk(
|
|
119
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
120
|
+
platform: NodeJS.Platform = process.platform,
|
|
121
|
+
): boolean {
|
|
122
|
+
if (platform === "win32") return false;
|
|
123
|
+
if (env.WT_SESSION) return true;
|
|
124
|
+
if (
|
|
125
|
+
env.WEZTERM_PANE ||
|
|
126
|
+
env.KITTY_WINDOW_ID ||
|
|
127
|
+
env.GHOSTTY_RESOURCES_DIR ||
|
|
128
|
+
env.ALACRITTY_WINDOW_ID ||
|
|
129
|
+
env.VTE_VERSION ||
|
|
130
|
+
env.ITERM_SESSION_ID
|
|
131
|
+
) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
switch (env.TERM_PROGRAM?.toLowerCase()) {
|
|
135
|
+
case "alacritty":
|
|
136
|
+
case "apple_terminal":
|
|
137
|
+
case "ghostty":
|
|
138
|
+
case "iterm.app":
|
|
139
|
+
case "kitty":
|
|
140
|
+
case "wezterm":
|
|
141
|
+
return true;
|
|
142
|
+
default:
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
94
146
|
function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null {
|
|
95
147
|
if (!process.stdout.isTTY) return null;
|
|
96
148
|
if (terminalId === "vscode" || terminalId === "alacritty") return null;
|
|
@@ -105,12 +157,12 @@ const KNOWN_TERMINALS = Object.freeze({
|
|
|
105
157
|
base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
|
|
106
158
|
trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
|
|
107
159
|
// Recognized terminals
|
|
108
|
-
kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99),
|
|
109
|
-
ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
|
|
110
|
-
wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
|
|
111
|
-
iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
|
|
160
|
+
kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true),
|
|
161
|
+
ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
|
|
162
|
+
wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
|
|
163
|
+
iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9, true),
|
|
112
164
|
vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
|
|
113
|
-
alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
|
|
165
|
+
alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell, true),
|
|
114
166
|
});
|
|
115
167
|
|
|
116
168
|
export const TERMINAL_ID: TerminalId = (() => {
|
|
@@ -155,26 +207,39 @@ export const TERMINAL_ID: TerminalId = (() => {
|
|
|
155
207
|
})();
|
|
156
208
|
|
|
157
209
|
export const TERMINAL = (() => {
|
|
158
|
-
|
|
210
|
+
let resolved = getTerminalInfo(TERMINAL_ID);
|
|
211
|
+
const eagerEraseScrollbackRisk = detectTerminalEagerEraseScrollbackRisk(Bun.env, process.platform);
|
|
212
|
+
if (resolved.eagerEraseScrollbackRisk !== eagerEraseScrollbackRisk) {
|
|
213
|
+
resolved = new TerminalInfo(
|
|
214
|
+
resolved.id,
|
|
215
|
+
resolved.imageProtocol,
|
|
216
|
+
resolved.trueColor,
|
|
217
|
+
resolved.hyperlinks,
|
|
218
|
+
resolved.notifyProtocol,
|
|
219
|
+
eagerEraseScrollbackRisk,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
const forcedImageProtocol = getForcedImageProtocol();
|
|
160
|
-
let resolved = terminal;
|
|
161
224
|
if (forcedImageProtocol !== undefined) {
|
|
162
225
|
resolved = new TerminalInfo(
|
|
163
|
-
|
|
226
|
+
resolved.id,
|
|
164
227
|
forcedImageProtocol,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
228
|
+
resolved.trueColor,
|
|
229
|
+
resolved.hyperlinks,
|
|
230
|
+
resolved.notifyProtocol,
|
|
231
|
+
resolved.eagerEraseScrollbackRisk,
|
|
168
232
|
);
|
|
169
|
-
} else if (!
|
|
170
|
-
const fallbackImageProtocol = getFallbackImageProtocol(
|
|
233
|
+
} else if (!resolved.imageProtocol) {
|
|
234
|
+
const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
|
|
171
235
|
if (fallbackImageProtocol) {
|
|
172
236
|
resolved = new TerminalInfo(
|
|
173
|
-
|
|
237
|
+
resolved.id,
|
|
174
238
|
fallbackImageProtocol,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
239
|
+
resolved.trueColor,
|
|
240
|
+
resolved.hyperlinks,
|
|
241
|
+
resolved.notifyProtocol,
|
|
242
|
+
resolved.eagerEraseScrollbackRisk,
|
|
178
243
|
);
|
|
179
244
|
}
|
|
180
245
|
}
|
|
@@ -188,6 +253,7 @@ export const TERMINAL = (() => {
|
|
|
188
253
|
resolved.trueColor,
|
|
189
254
|
false,
|
|
190
255
|
resolved.notifyProtocol,
|
|
256
|
+
resolved.eagerEraseScrollbackRisk,
|
|
191
257
|
);
|
|
192
258
|
}
|
|
193
259
|
return resolved;
|
package/src/terminal.ts
CHANGED
|
@@ -18,7 +18,6 @@ let activeTerminal: ProcessTerminal | null = null;
|
|
|
18
18
|
let terminalEverStarted = false;
|
|
19
19
|
|
|
20
20
|
const STD_INPUT_HANDLE = -10;
|
|
21
|
-
const STD_OUTPUT_HANDLE = -11;
|
|
22
21
|
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
23
22
|
/**
|
|
24
23
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
@@ -96,6 +95,32 @@ export interface Terminal {
|
|
|
96
95
|
/**
|
|
97
96
|
* Returns whether the native terminal viewport is at the scrollback tail when
|
|
98
97
|
* the host exposes that state. `undefined` means the terminal cannot report it.
|
|
98
|
+
*
|
|
99
|
+
* `ProcessTerminal` deliberately does not implement this — no real terminal
|
|
100
|
+
* can answer it truthfully:
|
|
101
|
+
*
|
|
102
|
+
* - POSIX terminals expose no scrollback-position API at all.
|
|
103
|
+
* - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
|
|
104
|
+
* Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
|
|
105
|
+
* ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
|
|
106
|
+
* pseudo-console buffer. That buffer is pinned to the visible grid —
|
|
107
|
+
* scrollback lives in the host UI, invisible to console APIs
|
|
108
|
+
* (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
|
|
109
|
+
* where the user scrolled. Trusting it let streaming-time rebuilds emit
|
|
110
|
+
* `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
|
|
111
|
+
* (Tabby and other ConPTY hosts). No env var distinguishes these hosts
|
|
112
|
+
* (Tabby sets none), so trust cannot be conditional on the environment.
|
|
113
|
+
* - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
|
|
114
|
+
* buffer, but its window follows the output cursor: a probe comparing
|
|
115
|
+
* `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
|
|
116
|
+
* following live output until all ~9001 buffer rows fill, permanently
|
|
117
|
+
* blocking checkpoint scrollback reconciliation.
|
|
118
|
+
*
|
|
119
|
+
* The renderer treats a missing implementation / `undefined` as "unknown":
|
|
120
|
+
* live mutations defer destructive rebuilds and reconcile native scrollback
|
|
121
|
+
* at explicit checkpoints (prompt submit), where the user's keystroke has
|
|
122
|
+
* already pinned the host viewport to the bottom. Only test terminals
|
|
123
|
+
* (xterm.js-backed) implement this with a real answer.
|
|
99
124
|
*/
|
|
100
125
|
isNativeViewportAtBottom?(): boolean | undefined;
|
|
101
126
|
|
|
@@ -113,60 +138,6 @@ function isWindowsSubsystemForLinux(): boolean {
|
|
|
113
138
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
114
139
|
}
|
|
115
140
|
|
|
116
|
-
/**
|
|
117
|
-
* Whether the native console viewport-position probe should be consulted.
|
|
118
|
-
*
|
|
119
|
-
* Returns `true` only on native Windows that is *not* fronted by Windows
|
|
120
|
-
* Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
|
|
121
|
-
* ConPTY pseudo-console — which is always pinned to its tail — and not about
|
|
122
|
-
* the user-visible scrollback in modern hosts. Treat any such host as
|
|
123
|
-
* unreportable so the renderer falls back to the deferred-rebuild path.
|
|
124
|
-
*
|
|
125
|
-
* Pure helper for unit testing; the runtime call site reads `$env` /
|
|
126
|
-
* `process.platform`. See #1635.
|
|
127
|
-
*/
|
|
128
|
-
export function shouldTrustNativeViewportProbe(
|
|
129
|
-
env: { WT_SESSION?: string | undefined } = $env,
|
|
130
|
-
platform: NodeJS.Platform = process.platform,
|
|
131
|
-
): boolean {
|
|
132
|
-
if (platform !== "win32") return false;
|
|
133
|
-
if (env.WT_SESSION) return false;
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Whether eager live-frame native scrollback rebuilds are unsafe for the
|
|
139
|
-
* current POSIX terminal when its viewport position is unobservable.
|
|
140
|
-
*
|
|
141
|
-
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
|
|
142
|
-
* terminals below, ED3 can disturb a reader parked in native scrollback during
|
|
143
|
-
* streaming: kitty/ghostty/alacritty clamp the scroll offset back to the active
|
|
144
|
-
* tail when saved lines are erased, and WezTerm is the reported POSIX host for
|
|
145
|
-
* #1682. Defer only the eager streaming opt-in on these hosts; direct
|
|
146
|
-
* user-input renders and explicit checkpoint rebuilds still pass their own
|
|
147
|
-
* `allowUnknownViewportMutation` / `allowUnknownViewport` flags.
|
|
148
|
-
*
|
|
149
|
-
* Pure helper for unit testing; the runtime call site reads `$env` /
|
|
150
|
-
* `process.platform`. See #1682.
|
|
151
|
-
*/
|
|
152
|
-
export function terminalHasEagerEraseScrollbackRisk(
|
|
153
|
-
env: {
|
|
154
|
-
WEZTERM_PANE?: string | undefined;
|
|
155
|
-
KITTY_WINDOW_ID?: string | undefined;
|
|
156
|
-
GHOSTTY_RESOURCES_DIR?: string | undefined;
|
|
157
|
-
ALACRITTY_WINDOW_ID?: string | undefined;
|
|
158
|
-
TERM_PROGRAM?: string | undefined;
|
|
159
|
-
} = $env,
|
|
160
|
-
platform: NodeJS.Platform = process.platform,
|
|
161
|
-
): boolean {
|
|
162
|
-
if (platform === "win32") return false;
|
|
163
|
-
if (env.WEZTERM_PANE || env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR || env.ALACRITTY_WINDOW_ID) {
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
const termProgram = env.TERM_PROGRAM?.toLowerCase();
|
|
167
|
-
return termProgram === "ghostty";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
141
|
/**
|
|
171
142
|
* Real terminal using process.stdin/stdout
|
|
172
143
|
*/
|
|
@@ -265,42 +236,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
265
236
|
}
|
|
266
237
|
}
|
|
267
238
|
|
|
268
|
-
/**
|
|
269
|
-
* Returns true when Windows' active console viewport is at the scrollback tail.
|
|
270
|
-
* POSIX terminals do not expose native scrollback position through a standard API.
|
|
271
|
-
*
|
|
272
|
-
* On native Windows running under Windows Terminal (the default modern
|
|
273
|
-
* host), the `kernel32` probe answers about the ConPTY pseudo-console — not
|
|
274
|
-
* the user-visible WT viewport — so it would always read "at bottom" while
|
|
275
|
-
* the user is scrolled up. Return `undefined` there so the renderer falls
|
|
276
|
-
* back to the POSIX-style deferred-rebuild path: streaming mutations stay
|
|
277
|
-
* non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
|
|
278
|
-
* checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
|
|
279
|
-
* is already pinned to the bottom by the editor keystroke. See #1635.
|
|
280
|
-
*/
|
|
281
|
-
isNativeViewportAtBottom(): boolean | undefined {
|
|
282
|
-
if (!shouldTrustNativeViewportProbe()) return undefined;
|
|
283
|
-
try {
|
|
284
|
-
const kernel32 = dlopen("kernel32.dll", {
|
|
285
|
-
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
286
|
-
GetConsoleScreenBufferInfo: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
|
|
287
|
-
});
|
|
288
|
-
try {
|
|
289
|
-
const handle = kernel32.symbols.GetStdHandle(STD_OUTPUT_HANDLE);
|
|
290
|
-
const info = new Uint8Array(22);
|
|
291
|
-
const infoPtr = ptr(info);
|
|
292
|
-
if (!infoPtr || !kernel32.symbols.GetConsoleScreenBufferInfo(handle, infoPtr)) return undefined;
|
|
293
|
-
const viewBottom = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(16, true);
|
|
294
|
-
const bufferHeight = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(2, true);
|
|
295
|
-
return viewBottom >= bufferHeight - 1;
|
|
296
|
-
} finally {
|
|
297
|
-
kernel32.close();
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
return undefined;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
239
|
/**
|
|
305
240
|
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
|
|
306
241
|
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
|
package/src/tui.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as path from "node:path";
|
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
9
|
-
import {
|
|
9
|
+
import type { Terminal } from "./terminal";
|
|
10
10
|
import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
|
|
11
11
|
import {
|
|
12
12
|
Ellipsis,
|
|
@@ -31,11 +31,22 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
|
31
31
|
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
|
32
32
|
// several terminals keep a "pending wrap" flag after an exact-width row, so a
|
|
33
33
|
// following cursor move can first wrap to the next row and produce staircase
|
|
34
|
-
// trails. The TUI emits explicit CRLFs and restores autowrap before leaving
|
|
35
|
-
//
|
|
34
|
+
// trails. The TUI emits explicit CRLFs and restores autowrap before leaving the
|
|
35
|
+
// paint. Synchronized output can be disabled for terminals with broken DEC 2026
|
|
36
|
+
// implementations; autowrap discipline stays on either way.
|
|
36
37
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
37
|
-
const
|
|
38
|
-
const
|
|
38
|
+
const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
|
|
39
|
+
const SYNC_OUTPUT_END = "\x1b[?2026l";
|
|
40
|
+
const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
41
|
+
const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
42
|
+
const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
|
|
43
|
+
const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
|
|
44
|
+
const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
|
|
45
|
+
const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
|
|
46
|
+
const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
47
|
+
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
48
|
+
const CURSOR_END = SYNC_OUTPUT_END;
|
|
49
|
+
const CURSOR_END_NO_SYNC = "";
|
|
39
50
|
|
|
40
51
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
41
52
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -157,10 +168,6 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
157
168
|
return undefined;
|
|
158
169
|
}
|
|
159
170
|
|
|
160
|
-
function isTermuxSession(): boolean {
|
|
161
|
-
return Boolean(process.env.TERMUX_VERSION);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
171
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
165
172
|
function isMultiplexerSession(): boolean {
|
|
166
173
|
return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
|
|
@@ -316,6 +323,11 @@ export class TUI extends Container {
|
|
|
316
323
|
#sixelProbeUnsubscribe?: () => void;
|
|
317
324
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
318
325
|
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
326
|
+
#synchronizedOutputEnabled = !$flag("PI_NO_SYNC_OUTPUT");
|
|
327
|
+
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
328
|
+
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
329
|
+
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
330
|
+
#cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
319
331
|
#maxLinesRendered = 0; // Line count from last render, used for viewport calculation
|
|
320
332
|
// Highest count of content rows currently sitting in terminal scrollback
|
|
321
333
|
// above the visible viewport. Used to detect shrink-across-viewport-boundary
|
|
@@ -332,7 +344,20 @@ export class TUI extends Container {
|
|
|
332
344
|
#forceViewportRepaintOnNextRender = false;
|
|
333
345
|
#allowUnknownViewportMutationOnNextRender = false;
|
|
334
346
|
#eagerNativeScrollbackRebuild = false;
|
|
347
|
+
// Set when eager mode is switched off; applied after the next frame is
|
|
348
|
+
// classified so teardown frames from the same event batch still render
|
|
349
|
+
// eagerly (see setEagerNativeScrollbackRebuild).
|
|
350
|
+
#eagerNativeScrollbackRebuildDisablePending = false;
|
|
351
|
+
#previousVisibleOverlayComponents: Component[] = [];
|
|
352
|
+
#visibleOverlayComponentsThisRender: Component[] = [];
|
|
335
353
|
#hasEverRendered = false;
|
|
354
|
+
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
355
|
+
// event invalidates the committed screen even when the dimensions net out
|
|
356
|
+
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
|
|
357
|
+
// budget): the terminal reflowed its buffer on each event, moving rows
|
|
358
|
+
// between the viewport and scrollback, so the previous frame no longer
|
|
359
|
+
// describes the screen. Tracking only the dimension delta misses this.
|
|
360
|
+
#resizeEventPending = false;
|
|
336
361
|
#stopped = false;
|
|
337
362
|
|
|
338
363
|
// Overlay stack for modal components rendered on top of base content
|
|
@@ -387,13 +412,28 @@ export class TUI extends Container {
|
|
|
387
412
|
* duplicate-free history and is meant for windows where output above the fold
|
|
388
413
|
* is actively re-rendering — e.g. a tool whose result is still streaming and
|
|
389
414
|
* re-laying-out rows that have already scrolled into history. A terminal that
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
415
|
+
* reports a *known*-scrolled viewport still defers, as does native Windows
|
|
416
|
+
* (the viewport is never observable there and ConPTY hosts erase host
|
|
417
|
+
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
418
|
+
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
419
|
+
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
|
|
420
|
+
* direct user-input rebuilds are unaffected.
|
|
421
|
+
*
|
|
422
|
+
* Disabling does not take effect until the next frame has been classified:
|
|
423
|
+
* the event batch that ends a foreground stream both removes its UI rows
|
|
424
|
+
* (loader/status teardown — a shrink) and clears this flag before the
|
|
425
|
+
* throttled render timer fires. If the flag dropped immediately, that
|
|
426
|
+
* teardown frame would hit the ED3-risk idle deferral and freeze on screen
|
|
427
|
+
* (stale spinner) until the next keystroke.
|
|
394
428
|
*/
|
|
395
429
|
setEagerNativeScrollbackRebuild(enabled: boolean): void {
|
|
396
|
-
|
|
430
|
+
if (enabled) {
|
|
431
|
+
this.#eagerNativeScrollbackRebuild = true;
|
|
432
|
+
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!this.#eagerNativeScrollbackRebuild) return;
|
|
436
|
+
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
|
397
437
|
}
|
|
398
438
|
|
|
399
439
|
setFocus(component: Component | null): void {
|
|
@@ -496,6 +536,14 @@ export class TUI extends Container {
|
|
|
496
536
|
return undefined;
|
|
497
537
|
}
|
|
498
538
|
|
|
539
|
+
#overlayVisibilityReduced(visibleComponents: readonly Component[]): boolean {
|
|
540
|
+
if (this.#previousVisibleOverlayComponents.length === 0) return false;
|
|
541
|
+
for (const component of this.#previousVisibleOverlayComponents) {
|
|
542
|
+
if (!visibleComponents.includes(component)) return true;
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
|
|
499
547
|
override invalidate(): void {
|
|
500
548
|
super.invalidate();
|
|
501
549
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
@@ -505,7 +553,10 @@ export class TUI extends Container {
|
|
|
505
553
|
this.#stopped = false;
|
|
506
554
|
this.terminal.start(
|
|
507
555
|
data => this.#handleInput(data),
|
|
508
|
-
() =>
|
|
556
|
+
() => {
|
|
557
|
+
this.#resizeEventPending = true;
|
|
558
|
+
this.requestRender();
|
|
559
|
+
},
|
|
509
560
|
);
|
|
510
561
|
this.terminal.hideCursor();
|
|
511
562
|
this.#querySixelSupport();
|
|
@@ -696,6 +747,14 @@ export class TUI extends Container {
|
|
|
696
747
|
*/
|
|
697
748
|
refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
|
|
698
749
|
if (!this.#nativeScrollbackDirty || this.#stopped) return false;
|
|
750
|
+
// Multiplexer panes preserve their own history and never receive a
|
|
751
|
+
// destructive clear, so a checkpoint "replay" cannot reconcile anything —
|
|
752
|
+
// it would only append a duplicate copy of the transcript to pane
|
|
753
|
+
// history. Drop the dirty flag; there is nothing actionable behind it.
|
|
754
|
+
if (isMultiplexerSession()) {
|
|
755
|
+
this.#clearNativeScrollbackDirty();
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
699
758
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
700
759
|
if (
|
|
701
760
|
!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, options?.allowUnknownViewport === true)
|
|
@@ -734,8 +793,12 @@ export class TUI extends Container {
|
|
|
734
793
|
const geometryChanged =
|
|
735
794
|
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
736
795
|
(this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
|
|
796
|
+
// A geometry replay rewraps clearable native scrollback at the new size.
|
|
797
|
+
// Inside a multiplexer the pane reflows its own history and a replay only
|
|
798
|
+
// duplicates it, so never promote forced renders to sessionReplace there.
|
|
737
799
|
const replayGeometry =
|
|
738
800
|
geometryChanged &&
|
|
801
|
+
!isMultiplexerSession() &&
|
|
739
802
|
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
|
|
740
803
|
this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
|
|
741
804
|
this.#forceViewportRepaintOnNextRender = true;
|
|
@@ -816,7 +879,7 @@ export class TUI extends Container {
|
|
|
816
879
|
return;
|
|
817
880
|
}
|
|
818
881
|
this.#focusedComponent.handleInput(data);
|
|
819
|
-
this.requestRender();
|
|
882
|
+
this.requestRender(false, { allowUnknownViewportMutation: true });
|
|
820
883
|
}
|
|
821
884
|
}
|
|
822
885
|
|
|
@@ -1173,10 +1236,15 @@ export class TUI extends Container {
|
|
|
1173
1236
|
|
|
1174
1237
|
// 1. Compose the frame.
|
|
1175
1238
|
let baseLines = this.render(width);
|
|
1176
|
-
|
|
1177
|
-
if (this.overlayStack.length > 0) {
|
|
1178
|
-
|
|
1239
|
+
const visibleOverlayComponents: Component[] = [];
|
|
1240
|
+
if (this.overlayStack.length > 0 || this.#previousVisibleOverlayComponents.length > 0) {
|
|
1241
|
+
for (const entry of this.overlayStack) {
|
|
1242
|
+
if (this.#isOverlayVisible(entry)) visibleOverlayComponents.push(entry.component);
|
|
1243
|
+
}
|
|
1179
1244
|
}
|
|
1245
|
+
this.#visibleOverlayComponentsThisRender = visibleOverlayComponents;
|
|
1246
|
+
const overlayVisibilityReduced = this.#overlayVisibilityReduced(visibleOverlayComponents);
|
|
1247
|
+
let lines = visibleOverlayComponents.length > 0 ? this.#compositeOverlays(baseLines, width, height) : baseLines;
|
|
1180
1248
|
const cursorPos = this.#extractCursorPosition(lines, height);
|
|
1181
1249
|
lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
|
|
1182
1250
|
if (lines !== baseLines) {
|
|
@@ -1187,9 +1255,17 @@ export class TUI extends Container {
|
|
|
1187
1255
|
// 2. Capture transition + pre-render state before any emitter runs.
|
|
1188
1256
|
const prevViewportTop = this.#viewportTopRow;
|
|
1189
1257
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1258
|
+
const resizeEventOccurred = this.#resizeEventPending;
|
|
1259
|
+
this.#resizeEventPending = false;
|
|
1190
1260
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1191
|
-
|
|
1192
|
-
|
|
1261
|
+
// A resize event with net-unchanged dimensions still reflowed the terminal
|
|
1262
|
+
// buffer; classify it as a height change so the geometry branches repaint
|
|
1263
|
+
// or rebuild instead of diffing against a screen that no longer exists.
|
|
1264
|
+
const heightChanged =
|
|
1265
|
+
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
1266
|
+
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1267
|
+
const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
|
|
1268
|
+
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1193
1269
|
const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender || eagerRebuildAllowed;
|
|
1194
1270
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1195
1271
|
|
|
@@ -1200,8 +1276,14 @@ export class TUI extends Container {
|
|
|
1200
1276
|
heightChanged,
|
|
1201
1277
|
prevViewportTop,
|
|
1202
1278
|
height,
|
|
1279
|
+
visibleOverlayComponents.length > 0,
|
|
1280
|
+
overlayVisibilityReduced,
|
|
1203
1281
|
allowUnknownViewportMutation,
|
|
1204
1282
|
);
|
|
1283
|
+
if (this.#eagerNativeScrollbackRebuildDisablePending) {
|
|
1284
|
+
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
1285
|
+
this.#eagerNativeScrollbackRebuild = false;
|
|
1286
|
+
}
|
|
1205
1287
|
this.#logRedraw(intent, lines.length, height);
|
|
1206
1288
|
// 4. Execute.
|
|
1207
1289
|
switch (intent.kind) {
|
|
@@ -1287,6 +1369,8 @@ export class TUI extends Container {
|
|
|
1287
1369
|
heightChanged: boolean,
|
|
1288
1370
|
prevViewportTop: number,
|
|
1289
1371
|
height: number,
|
|
1372
|
+
hasVisibleOverlay: boolean,
|
|
1373
|
+
overlayVisibilityReduced: boolean,
|
|
1290
1374
|
allowUnknownViewportMutation: boolean,
|
|
1291
1375
|
): RenderIntent {
|
|
1292
1376
|
// Initial paint after start(): scrollback must keep its prior shell
|
|
@@ -1298,10 +1382,20 @@ export class TUI extends Container {
|
|
|
1298
1382
|
if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
|
|
1299
1383
|
|
|
1300
1384
|
const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
|
|
1301
|
-
|
|
1385
|
+
const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
|
|
1386
|
+
if (overlayVisibilityReduced && !isMultiplexerSession()) {
|
|
1387
|
+
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1388
|
+
}
|
|
1389
|
+
if (hasVisibleOverlay) {
|
|
1302
1390
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1391
|
+
// Multiplexer panes never get a destructive scrollback clear
|
|
1392
|
+
// (clearScrollback is forced off inside them), so a dirty-scrollback
|
|
1393
|
+
// "rebuild" would only append a full duplicate copy of the transcript
|
|
1394
|
+
// to pane history on every dirty frame. Keep repainting the viewport
|
|
1395
|
+
// and leave reconciliation to explicit checkpoints.
|
|
1303
1396
|
if (
|
|
1304
1397
|
this.#nativeScrollbackDirty &&
|
|
1398
|
+
!isMultiplexerSession() &&
|
|
1305
1399
|
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1306
1400
|
) {
|
|
1307
1401
|
return { kind: "overlayRebuild" };
|
|
@@ -1310,7 +1404,11 @@ export class TUI extends Container {
|
|
|
1310
1404
|
return { kind: "viewportRepaint" };
|
|
1311
1405
|
}
|
|
1312
1406
|
|
|
1313
|
-
if (
|
|
1407
|
+
if (
|
|
1408
|
+
this.#nativeScrollbackDirty &&
|
|
1409
|
+
!isMultiplexerSession() &&
|
|
1410
|
+
this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)
|
|
1411
|
+
) {
|
|
1314
1412
|
return { kind: "historyRebuild" };
|
|
1315
1413
|
}
|
|
1316
1414
|
|
|
@@ -1359,29 +1457,64 @@ export class TUI extends Container {
|
|
|
1359
1457
|
return { kind: "viewportRepaint" };
|
|
1360
1458
|
}
|
|
1361
1459
|
// The shrunk transcript still overflows the viewport. A plain viewport
|
|
1362
|
-
// repaint
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1460
|
+
// repaint can duplicate stale rows in native scrollback, while a destructive
|
|
1461
|
+
// history rebuild (`CSI 3 J`) can yank readers in ED3-risk terminals whose
|
|
1462
|
+
// viewport position is unobservable. Outside foreground-tool streaming,
|
|
1463
|
+
// keep the old visible history frozen and reconcile at the next explicit
|
|
1464
|
+
// checkpoint. During a foreground tool, a literal no-op freezes the live
|
|
1465
|
+
// command/status view; continue with a non-destructive repaint path instead.
|
|
1466
|
+
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk && !this.#eagerNativeScrollbackRebuild) {
|
|
1467
|
+
this.#markNativeScrollbackDirty();
|
|
1468
|
+
return { kind: "deferredMutation" };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// If the shrink still leaves enough rows to cover the previous viewport
|
|
1472
|
+
// top, `deferredShrink` can repaint that stable slice without committing
|
|
1473
|
+
// duplicate rows to native scrollback. When the shrink jumps above that
|
|
1474
|
+
// padded viewport top, `deferredShrink` would draw only blank padding and
|
|
1475
|
+
// hide the live prompt. Ordinary POSIX terminals rebuild history in that
|
|
1476
|
+
// case; ED3-risk foreground-tool frames use a non-destructive viewport
|
|
1477
|
+
// repaint and leave stale scrollback queued for the next checkpoint.
|
|
1377
1478
|
const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
1378
1479
|
if (newLines.length <= paddedViewportTop) {
|
|
1480
|
+
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
|
|
1481
|
+
this.#markNativeScrollbackDirty();
|
|
1482
|
+
return { kind: "viewportRepaint" };
|
|
1483
|
+
}
|
|
1379
1484
|
return { kind: "historyRebuild" };
|
|
1380
1485
|
}
|
|
1381
1486
|
this.#markNativeScrollbackDirty();
|
|
1382
1487
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1383
1488
|
}
|
|
1384
1489
|
|
|
1490
|
+
// Multiplexer panes do not give us a safe native-history rebuild path, but
|
|
1491
|
+
// a shrink can still move the logical viewport upward (for example hiding an
|
|
1492
|
+
// overlay that extended past the base frame). A row-diff from the old
|
|
1493
|
+
// viewport top would only clear the old suffix and leave the newly exposed
|
|
1494
|
+
// base rows stale/blank, so repaint the live viewport in place.
|
|
1495
|
+
if (
|
|
1496
|
+
isMultiplexerSession() &&
|
|
1497
|
+
diff.firstChanged !== -1 &&
|
|
1498
|
+
newLines.length < this.#previousLines.length &&
|
|
1499
|
+
naturalViewportTop !== prevViewportTop
|
|
1500
|
+
) {
|
|
1501
|
+
return { kind: "viewportRepaint" };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Direct-input shrink can also move the natural viewport upward even when
|
|
1505
|
+
// no stale high-water scrollback is involved (for example slash autocomplete
|
|
1506
|
+
// filtering from many rows to a few). The diff emitter is anchored to the
|
|
1507
|
+
// previous viewport top and would only clear the old suffix, hiding the
|
|
1508
|
+
// editor above the live window.
|
|
1509
|
+
if (
|
|
1510
|
+
allowUnknownViewportMutation &&
|
|
1511
|
+
diff.firstChanged !== -1 &&
|
|
1512
|
+
newLines.length < this.#previousLines.length &&
|
|
1513
|
+
naturalViewportTop !== prevViewportTop
|
|
1514
|
+
) {
|
|
1515
|
+
return { kind: "viewportRepaint" };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1385
1518
|
const suppressSuffixScroll = this.#suppressNextSuffixScroll;
|
|
1386
1519
|
this.#suppressNextSuffixScroll = false;
|
|
1387
1520
|
if (
|
|
@@ -1418,27 +1551,29 @@ export class TUI extends Container {
|
|
|
1418
1551
|
// viewport, but it must keep the existing diff basis so later coalesced
|
|
1419
1552
|
// content mutations can still update native scrollback correctly.
|
|
1420
1553
|
if (forceViewportRepaint) return { kind: "viewportRepaint" };
|
|
1421
|
-
// Width
|
|
1422
|
-
//
|
|
1554
|
+
// Width changes alter wrapping geometry; height changes expose or hide
|
|
1555
|
+
// viewport rows. Repaint any non-multiplexer resize, including Termux
|
|
1556
|
+
// software-keyboard toggles: leaving the new rows blank creates phantom
|
|
1557
|
+
// viewport space that later appends can fill without growing scrollback.
|
|
1423
1558
|
if (widthChanged) return { kind: "viewportRepaint" };
|
|
1424
|
-
if (heightChanged && !
|
|
1559
|
+
if (heightChanged && !isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1425
1560
|
return { kind: "noop" };
|
|
1426
1561
|
}
|
|
1427
1562
|
|
|
1428
|
-
// Width changes rewrap
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1563
|
+
// Width changes rewrap native history. Any non-append content change must
|
|
1564
|
+
// rebuild the committed transcript when the viewport is safe; a viewport
|
|
1565
|
+
// repaint can leave old-width wrapped fragments above the live frame, and
|
|
1566
|
+
// later appends then splice new rows onto stale history.
|
|
1432
1567
|
if (widthChanged) {
|
|
1433
|
-
|
|
1434
|
-
|
|
1568
|
+
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1569
|
+
if (!pureAppend) {
|
|
1570
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1571
|
+
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1435
1572
|
this.#markNativeScrollbackDirty();
|
|
1436
1573
|
return { kind: "viewportRepaint" };
|
|
1437
1574
|
}
|
|
1438
|
-
return { kind: "historyRebuild" };
|
|
1575
|
+
return isMultiplexerSession() ? { kind: "viewportRepaint" } : { kind: "historyRebuild" };
|
|
1439
1576
|
}
|
|
1440
|
-
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1441
|
-
if (!pureAppend) return { kind: "viewportRepaint" };
|
|
1442
1577
|
}
|
|
1443
1578
|
|
|
1444
1579
|
const contentGrew = newLines.length > this.#previousLines.length;
|
|
@@ -1505,10 +1640,13 @@ export class TUI extends Container {
|
|
|
1505
1640
|
}
|
|
1506
1641
|
}
|
|
1507
1642
|
|
|
1508
|
-
// Height changes shift the visible window. Repaint when content didn't
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1643
|
+
// Height changes shift the visible window. Repaint when content didn't grow,
|
|
1644
|
+
// but skip inside multiplexers (panes manage their own redraws — handled by
|
|
1645
|
+
// the multiplexer geometry branch below). Termux is deliberately included:
|
|
1646
|
+
// a resize with no content change still exposes or hides viewport rows, and
|
|
1647
|
+
// leaving those rows blank lets later appends fill phantom space instead of
|
|
1648
|
+
// growing native scrollback.
|
|
1649
|
+
if (heightChanged && !contentGrew && !isMultiplexerSession()) {
|
|
1512
1650
|
return { kind: "viewportRepaint" };
|
|
1513
1651
|
}
|
|
1514
1652
|
|
|
@@ -1519,7 +1657,47 @@ export class TUI extends Container {
|
|
|
1519
1657
|
// tail lands `height`-delta rows too low. With no overflow there is no
|
|
1520
1658
|
// native scrollback to preserve, so repaint the viewport at the new
|
|
1521
1659
|
// geometry. (Height changes with overflow keep the existing deferral.)
|
|
1522
|
-
if (heightChanged && newLines.length <= height && !
|
|
1660
|
+
if (heightChanged && newLines.length <= height && !isMultiplexerSession()) {
|
|
1661
|
+
return { kind: "viewportRepaint" };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Any other geometry change (height shrink with content overflowing the
|
|
1665
|
+
// viewport, or a width change carrying a pure append) must not reach the
|
|
1666
|
+
// anchor-relative diff/append emitters below either. The terminal reflowed
|
|
1667
|
+
// its own buffer on resize — a height shrink moves committed rows between
|
|
1668
|
+
// scrollback and viewport — so the previous frame's viewport-top and
|
|
1669
|
+
// hardware-cursor anchors no longer describe the screen, and scrolling
|
|
1670
|
+
// relative to them splices phantom blank rows into native scrollback
|
|
1671
|
+
// (stress repro: darwin-normal-large seed 0x5eed1234 op 1062, a
|
|
1672
|
+
// resizeHeight coalesced with a streamed append). A resize is an explicit
|
|
1673
|
+
// user action, so rebuilding history at the new geometry is the
|
|
1674
|
+
// established tradeoff (see the width-change branch above); a reader
|
|
1675
|
+
// confirmed scrolled into history is still never yanked. Termux is included
|
|
1676
|
+
// (it is not a multiplexer and ED3 clears its own scrollback): a content-
|
|
1677
|
+
// bearing resize must not reach the stale-anchor emitters below.
|
|
1678
|
+
if ((heightChanged || widthChanged) && !isMultiplexerSession()) {
|
|
1679
|
+
// No overflow → nothing of ours in native scrollback to reconcile; an
|
|
1680
|
+
// in-place repaint also keeps preexisting shell scrollback intact.
|
|
1681
|
+
if (newLines.length <= height) {
|
|
1682
|
+
return { kind: "viewportRepaint" };
|
|
1683
|
+
}
|
|
1684
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1685
|
+
if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
|
|
1686
|
+
this.#markNativeScrollbackDirty();
|
|
1687
|
+
return { kind: "viewportRepaint" };
|
|
1688
|
+
}
|
|
1689
|
+
return { kind: "historyRebuild" };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// The same geometry hazard inside a multiplexer: tmux reflows the pane
|
|
1693
|
+
// grid (visible rows AND pane history) on resize, so the anchor-relative
|
|
1694
|
+
// diff/append emitters below are equally invalid — but a destructive
|
|
1695
|
+
// rebuild is impossible there (pane history cannot be cleared; a full
|
|
1696
|
+
// replay only appends a duplicate transcript copy). Repaint the visible
|
|
1697
|
+
// window in place at the new geometry. Applies even under Termux: a
|
|
1698
|
+
// repaint per keyboard-toggle resize is cheaper than splicing phantom
|
|
1699
|
+
// rows into the pane.
|
|
1700
|
+
if ((heightChanged || widthChanged) && isMultiplexerSession()) {
|
|
1523
1701
|
return { kind: "viewportRepaint" };
|
|
1524
1702
|
}
|
|
1525
1703
|
|
|
@@ -1635,7 +1813,12 @@ export class TUI extends Container {
|
|
|
1635
1813
|
}
|
|
1636
1814
|
|
|
1637
1815
|
#readNativeViewportAtBottom(): boolean | undefined {
|
|
1638
|
-
|
|
1816
|
+
// A stale positive is destructive: live history rebuilds clear native
|
|
1817
|
+
// scrollback. Require two consecutive at-bottom probes before trusting it.
|
|
1818
|
+
const first = this.terminal.isNativeViewportAtBottom?.();
|
|
1819
|
+
if (first !== true) return first;
|
|
1820
|
+
const second = this.terminal.isNativeViewportAtBottom?.();
|
|
1821
|
+
return second === true ? true : second;
|
|
1639
1822
|
}
|
|
1640
1823
|
|
|
1641
1824
|
#nativeViewportIsScrolled(
|
|
@@ -1652,10 +1835,6 @@ export class TUI extends Container {
|
|
|
1652
1835
|
return nativeViewportAtBottom === false;
|
|
1653
1836
|
}
|
|
1654
1837
|
|
|
1655
|
-
#nativeViewportIsAtBottom(nativeViewportAtBottom: boolean | undefined): boolean {
|
|
1656
|
-
return nativeViewportAtBottom === true;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
1838
|
#canReplayNativeScrollbackAtCheckpoint(
|
|
1660
1839
|
nativeViewportAtBottom: boolean | undefined,
|
|
1661
1840
|
allowUnknownViewport: boolean,
|
|
@@ -1680,15 +1859,18 @@ export class TUI extends Container {
|
|
|
1680
1859
|
* this, every offscreen transcript edit while streaming wiped scrollback and
|
|
1681
1860
|
* yanked a scrolled-up reader out of their current context.
|
|
1682
1861
|
* `allowUnknownViewportMutation` (autocomplete/IME) opts directly
|
|
1683
|
-
* user-driven frames back into the rebuild.
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1862
|
+
* user-driven POSIX frames back into the rebuild. Native Windows and Windows
|
|
1863
|
+
* Terminal still cannot trust an unknown probe during live rendering — ConPTY
|
|
1864
|
+
* may be fronting host scrollback we cannot observe — so they keep deferring.
|
|
1686
1865
|
*/
|
|
1687
1866
|
#canRebuildNativeScrollbackLive(
|
|
1688
1867
|
nativeViewportAtBottom: boolean | undefined,
|
|
1689
1868
|
allowUnknownViewportMutation: boolean,
|
|
1690
1869
|
): boolean {
|
|
1691
|
-
return
|
|
1870
|
+
return (
|
|
1871
|
+
nativeViewportAtBottom === true ||
|
|
1872
|
+
(nativeViewportAtBottom === undefined && allowUnknownViewportMutation && process.platform !== "win32")
|
|
1873
|
+
);
|
|
1692
1874
|
}
|
|
1693
1875
|
|
|
1694
1876
|
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
|
@@ -1720,8 +1902,10 @@ export class TUI extends Container {
|
|
|
1720
1902
|
* Single state-transition point. Every emitter calls this exactly once at
|
|
1721
1903
|
* the end so cursor/viewport/scrollback accounting stays consistent.
|
|
1722
1904
|
*/
|
|
1905
|
+
|
|
1723
1906
|
#commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
|
|
1724
1907
|
this.#previousLines = lines;
|
|
1908
|
+
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
|
|
1725
1909
|
this.#forceViewportRepaintOnNextRender = false;
|
|
1726
1910
|
this.#previousWidth = width;
|
|
1727
1911
|
this.#previousHeight = height;
|
|
@@ -1742,7 +1926,7 @@ export class TUI extends Container {
|
|
|
1742
1926
|
options: { clearViewport: boolean; clearScrollback: boolean },
|
|
1743
1927
|
): void {
|
|
1744
1928
|
this.#fullRedrawCount += 1;
|
|
1745
|
-
let buffer =
|
|
1929
|
+
let buffer = this.#paintBeginSequence;
|
|
1746
1930
|
if (options.clearViewport) {
|
|
1747
1931
|
buffer += options.clearScrollback ? "\x1b[2J\x1b[H\x1b[3J" : "\x1b[2J\x1b[H";
|
|
1748
1932
|
}
|
|
@@ -1753,7 +1937,7 @@ export class TUI extends Container {
|
|
|
1753
1937
|
const finalRow = Math.max(0, lines.length - 1);
|
|
1754
1938
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
1755
1939
|
buffer += seq;
|
|
1756
|
-
buffer +=
|
|
1940
|
+
buffer += this.#paintEndSequence;
|
|
1757
1941
|
this.terminal.write(buffer);
|
|
1758
1942
|
|
|
1759
1943
|
this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
|
|
@@ -1780,7 +1964,7 @@ export class TUI extends Container {
|
|
|
1780
1964
|
): void {
|
|
1781
1965
|
this.#fullRedrawCount += 1;
|
|
1782
1966
|
const viewportTop = Math.max(0, lines.length - height);
|
|
1783
|
-
let buffer = `${
|
|
1967
|
+
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
1784
1968
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
1785
1969
|
if (screenRow > 0) buffer += "\r\n";
|
|
1786
1970
|
buffer += "\x1b[2K";
|
|
@@ -1797,7 +1981,7 @@ export class TUI extends Container {
|
|
|
1797
1981
|
const finalRow = viewportTop + height - 1;
|
|
1798
1982
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
1799
1983
|
buffer += seq;
|
|
1800
|
-
buffer +=
|
|
1984
|
+
buffer += this.#paintEndSequence;
|
|
1801
1985
|
this.terminal.write(buffer);
|
|
1802
1986
|
|
|
1803
1987
|
this.#maxLinesRendered = lines.length;
|
|
@@ -1819,7 +2003,7 @@ export class TUI extends Container {
|
|
|
1819
2003
|
prevHardwareCursorRow: number,
|
|
1820
2004
|
): void {
|
|
1821
2005
|
if (start >= lines.length) return;
|
|
1822
|
-
let buffer =
|
|
2006
|
+
let buffer = this.#paintBeginSequence;
|
|
1823
2007
|
// Clamp tracked cursor to the visible viewport bottom — terminals clamp
|
|
1824
2008
|
// on resize, so a prior frame may have committed a row that no longer
|
|
1825
2009
|
// exists. Without this the scroll math points outside the viewport.
|
|
@@ -1831,7 +2015,7 @@ export class TUI extends Container {
|
|
|
1831
2015
|
buffer += "\r\n";
|
|
1832
2016
|
buffer += this.#fitLineToWidth(lines[i], width);
|
|
1833
2017
|
}
|
|
1834
|
-
buffer +=
|
|
2018
|
+
buffer += this.#paintEndSequence;
|
|
1835
2019
|
this.terminal.write(buffer);
|
|
1836
2020
|
const pushedNow = Math.max(0, lines.length - height);
|
|
1837
2021
|
if (pushedNow > this.#scrollbackHighWater) {
|
|
@@ -1867,7 +2051,7 @@ export class TUI extends Container {
|
|
|
1867
2051
|
const viewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
1868
2052
|
const targetRow = Math.max(0, lines.length - 1);
|
|
1869
2053
|
|
|
1870
|
-
let buffer =
|
|
2054
|
+
let buffer = this.#paintBeginSequence;
|
|
1871
2055
|
|
|
1872
2056
|
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
|
|
1873
2057
|
const currentScreenRow = clampedCursor - prevViewportTop;
|
|
@@ -1892,7 +2076,7 @@ export class TUI extends Container {
|
|
|
1892
2076
|
|
|
1893
2077
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
|
|
1894
2078
|
buffer += seq;
|
|
1895
|
-
buffer +=
|
|
2079
|
+
buffer += this.#paintEndSequence;
|
|
1896
2080
|
this.terminal.write(buffer);
|
|
1897
2081
|
|
|
1898
2082
|
this.#maxLinesRendered = lines.length;
|
|
@@ -1926,7 +2110,7 @@ export class TUI extends Container {
|
|
|
1926
2110
|
const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
|
|
1927
2111
|
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
|
1928
2112
|
|
|
1929
|
-
let buffer =
|
|
2113
|
+
let buffer = this.#paintBeginSequence;
|
|
1930
2114
|
|
|
1931
2115
|
// Scroll-down branch: target row is past the bottom of the previous
|
|
1932
2116
|
// viewport (a pure append). Emit `\r\n`s so the terminal pushes the
|
|
@@ -1977,7 +2161,7 @@ export class TUI extends Container {
|
|
|
1977
2161
|
|
|
1978
2162
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
|
|
1979
2163
|
buffer += seq;
|
|
1980
|
-
buffer +=
|
|
2164
|
+
buffer += this.#paintEndSequence;
|
|
1981
2165
|
|
|
1982
2166
|
this.#writeDiffDebug(
|
|
1983
2167
|
lines,
|
|
@@ -2107,6 +2291,6 @@ export class TUI extends Container {
|
|
|
2107
2291
|
}
|
|
2108
2292
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
2109
2293
|
this.#hardwareCursorRow = toRow;
|
|
2110
|
-
this.terminal.write(`${
|
|
2294
|
+
this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
|
|
2111
2295
|
}
|
|
2112
2296
|
}
|