@oh-my-pi/pi-tui 15.7.2 → 15.7.5
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 +31 -0
- package/dist/types/components/select-list.d.ts +2 -0
- package/dist/types/terminal.d.ts +24 -0
- package/dist/types/tui.d.ts +12 -0
- package/package.json +4 -4
- package/src/components/editor.ts +7 -5
- package/src/components/select-list.ts +92 -16
- package/src/terminal.ts +31 -1
- package/src/tui.ts +256 -65
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.7.5] - 2026-06-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed native Windows + Windows Terminal scrollback being yanked to the top when a streaming response triggered a TUI full redraw. Under ConPTY the `kernel32` `GetConsoleScreenBufferInfo` probe answers about the pseudo-console (always at the buffer tail) and not about WT's host scrollback, so `isNativeViewportAtBottom()` falsely returned `true` while the user was scrolled up and the shrink-across-viewport branch issued a destructive `historyRebuild` (`\x1b[2J\x1b[H\x1b[3J`). The probe now short-circuits to `undefined` whenever `WT_SESSION` is set, letting the existing deferred-rebuild path keep streaming-time mutations non-destructive and reconcile native history at the next prompt-submit checkpoint. ([#1635](https://github.com/can1357/oh-my-pi/issues/1635))
|
|
10
|
+
|
|
11
|
+
## [15.7.3] - 2026-05-31
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `overflowSearch` to `SelectListLayoutOptions` to let consumers enable or disable type-to-filter search and search-status rendering per SelectList instance
|
|
16
|
+
- Added fuzzy type-to-filter search to overflowing `SelectList` pickers, with search status and result counts.
|
|
17
|
+
- Added `TUI.setEagerNativeScrollbackRebuild(enabled)` — while enabled, live render frames rebuild native scrollback on offscreen/structural changes even when the viewport position is unobservable (POSIX), instead of deferring to a non-destructive repaint. Trades the anti-yank guarantee for clean, duplicate-free history; intended for windows where output above the fold is actively re-laying out (e.g. a tool whose result is still streaming). A terminal that reports a known-scrolled viewport still defers.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Disabled interactive search filtering for editor autocomplete and slash-command `SelectList`s by passing `overflowSearch: false` in their layout options
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Preserved hidden tmux overlays in the live viewport by removing overlay content from view when an overlay was hidden while keeping pane history intact
|
|
26
|
+
- Preserved native scrollback when forced TUI renders coalesce with content growth, and deferred pure tail appends while readers are scrolled into history.
|
|
27
|
+
- Preserved existing terminal scrollback during forced and structural TUI renders so preexisting shell lines remained visible after component mutations
|
|
28
|
+
- Rebuilt native scrollback for safe bottom-anchored offscreen edits and high-water preview collapses instead of repainting only the viewport, preventing stale or duplicated rows above the live viewport.
|
|
29
|
+
- Stripped internal cursor marker sentinels from all rendered lines so offscreen focus markers no longer leak into terminal output
|
|
30
|
+
- Truncated all painted lines to terminal width during viewport repaints and append-tail updates so long content no longer overflows or wraps unexpectedly
|
|
31
|
+
- Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown
|
|
32
|
+
- Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer.
|
|
33
|
+
- Fixed scrolled-up readers being yanked back to the tail whenever streaming content arrived on POSIX terminals (macOS/Linux). Native viewport position is unobservable there (`isNativeViewportAtBottom()` returns `undefined`), and the planner optimistically treated "unknown" as "at bottom", so every offscreen streaming edit ran a destructive `historyRebuild` that cleared scrollback and snapped the view to the bottom. Live render frames now treat an unknown viewport as unsafe for a destructive rebuild — they defer to a non-destructive viewport repaint and reconcile native scrollback at the next explicit checkpoint (prompt submit). Resize and checkpoint replays keep the prior behavior.
|
|
34
|
+
- Fixed native scrollback not rewrapping when the terminal widens on POSIX. A width increase reflows the transcript to fewer lines, which the shrink-across-boundary branch intercepted and (after the unknown-viewport deferral) repainted only the viewport — leaving committed history wrapped at the old width and duplicated above the live viewport. Width changes now rebuild native scrollback at the new geometry even when the viewport position is unknown (a yank is acceptable on an explicit resize); a terminal that can report a scrolled viewport still defers.
|
|
35
|
+
|
|
5
36
|
## [15.7.0] - 2026-05-31
|
|
6
37
|
|
|
7
38
|
### Fixed
|
|
@@ -26,6 +26,8 @@ export interface SelectListLayoutOptions {
|
|
|
26
26
|
minPrimaryColumnWidth?: number;
|
|
27
27
|
maxPrimaryColumnWidth?: number;
|
|
28
28
|
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
|
29
|
+
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
|
|
30
|
+
overflowSearch?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export declare class SelectList implements Component {
|
|
31
33
|
#private;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -41,6 +41,21 @@ export interface Terminal {
|
|
|
41
41
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
42
42
|
get appearance(): TerminalAppearance | undefined;
|
|
43
43
|
}
|
|
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;
|
|
44
59
|
/**
|
|
45
60
|
* Real terminal using process.stdin/stdout
|
|
46
61
|
*/
|
|
@@ -53,6 +68,15 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
53
68
|
/**
|
|
54
69
|
* Returns true when Windows' active console viewport is at the scrollback tail.
|
|
55
70
|
* POSIX terminals do not expose native scrollback position through a standard API.
|
|
71
|
+
*
|
|
72
|
+
* On native Windows running under Windows Terminal (the default modern
|
|
73
|
+
* host), the `kernel32` probe answers about the ConPTY pseudo-console — not
|
|
74
|
+
* the user-visible WT viewport — so it would always read "at bottom" while
|
|
75
|
+
* the user is scrolled up. Return `undefined` there so the renderer falls
|
|
76
|
+
* back to the POSIX-style deferred-rebuild path: streaming mutations stay
|
|
77
|
+
* non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
|
|
78
|
+
* checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
|
|
79
|
+
* is already pinned to the bottom by the editor keystroke. See #1635.
|
|
56
80
|
*/
|
|
57
81
|
isNativeViewportAtBottom(): boolean | undefined;
|
|
58
82
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -164,6 +164,18 @@ export declare class TUI extends Container {
|
|
|
164
164
|
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
165
165
|
*/
|
|
166
166
|
setClearOnShrink(enabled: boolean): void;
|
|
167
|
+
/**
|
|
168
|
+
* When enabled, live render frames rebuild native scrollback on offscreen and
|
|
169
|
+
* structural changes even when the viewport position is unobservable (POSIX,
|
|
170
|
+
* where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
|
|
171
|
+
* non-destructive repaint. This trades the anti-yank guarantee for a clean,
|
|
172
|
+
* duplicate-free history and is meant for windows where output above the fold
|
|
173
|
+
* is actively re-rendering — e.g. a tool whose result is still streaming and
|
|
174
|
+
* re-laying-out rows that have already scrolled into history. A snap to the tail
|
|
175
|
+
* is acceptable there. A terminal that can report a *known*-scrolled viewport
|
|
176
|
+
* (Windows) still defers; only the unknown case is forced to rebuild.
|
|
177
|
+
*/
|
|
178
|
+
setEagerNativeScrollbackRebuild(enabled: boolean): void;
|
|
167
179
|
setFocus(component: Component | null): void;
|
|
168
180
|
/**
|
|
169
181
|
* Show an overlay component with configurable positioning and sizing.
|
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.7.
|
|
4
|
+
"version": "15.7.5",
|
|
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",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"check": "biome check . && bun run check:types",
|
|
33
33
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
34
34
|
"lint": "biome lint .",
|
|
35
|
-
"test": "bun test test/*.test.ts",
|
|
35
|
+
"test": "bun test --parallel test/*.test.ts",
|
|
36
36
|
"fix": "biome check --write --unsafe .",
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.7.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.7.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.7.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.7.5",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -19,9 +19,14 @@ import {
|
|
|
19
19
|
} from "../utils";
|
|
20
20
|
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
|
21
21
|
|
|
22
|
+
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
23
|
+
overflowSearch: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
22
26
|
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
23
27
|
minPrimaryColumnWidth: 12,
|
|
24
28
|
maxPrimaryColumnWidth: 32,
|
|
29
|
+
overflowSearch: false,
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
function sanitizeLoadedText(text: string): string {
|
|
@@ -2524,11 +2529,8 @@ export class Editor implements Component, Focusable {
|
|
|
2524
2529
|
prefix: string,
|
|
2525
2530
|
items: Array<{ value: string; label: string; description?: string }>,
|
|
2526
2531
|
): SelectList {
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
// TODO: Pass layout to SelectList when constructor is updated to support it
|
|
2530
|
-
void layout; // Use layout variable to avoid lint warnings
|
|
2531
|
-
return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList);
|
|
2532
|
+
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT;
|
|
2533
|
+
return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
|
|
2532
2534
|
}
|
|
2533
2535
|
|
|
2534
2536
|
#handleTabCompletion(): void {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { fuzzyFilter } from "../fuzzy";
|
|
1
2
|
import { getKeybindings } from "../keybindings";
|
|
3
|
+
import { extractPrintableText } from "../keys";
|
|
2
4
|
import type { SymbolTheme } from "../symbols";
|
|
3
5
|
import type { Component } from "../tui";
|
|
4
6
|
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
@@ -45,10 +47,13 @@ export interface SelectListLayoutOptions {
|
|
|
45
47
|
minPrimaryColumnWidth?: number;
|
|
46
48
|
maxPrimaryColumnWidth?: number;
|
|
47
49
|
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
|
50
|
+
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
|
|
51
|
+
overflowSearch?: boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export class SelectList implements Component {
|
|
51
55
|
#filteredItems: ReadonlyArray<SelectItem>;
|
|
56
|
+
#filterQuery = "";
|
|
52
57
|
#selectedIndex: number = 0;
|
|
53
58
|
|
|
54
59
|
onSelect?: (item: SelectItem) => void;
|
|
@@ -65,9 +70,7 @@ export class SelectList implements Component {
|
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
setFilter(filter: string): void {
|
|
68
|
-
this.#
|
|
69
|
-
// Reset selection when filter changes
|
|
70
|
-
this.#selectedIndex = 0;
|
|
73
|
+
this.#setFilter(filter, true);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
setSelectedIndex(index: number): void {
|
|
@@ -80,10 +83,14 @@ export class SelectList implements Component {
|
|
|
80
83
|
|
|
81
84
|
render(width: number): string[] {
|
|
82
85
|
const lines: string[] = [];
|
|
86
|
+
const showSearchStatus = this.#shouldRenderSearchStatus();
|
|
83
87
|
|
|
84
88
|
// If no items match filter, show message
|
|
85
89
|
if (this.#filteredItems.length === 0) {
|
|
86
|
-
|
|
90
|
+
if (showSearchStatus) {
|
|
91
|
+
lines.push(this.#renderStatusLine(width));
|
|
92
|
+
}
|
|
93
|
+
lines.push(this.theme.noMatch(" No matching items"));
|
|
87
94
|
return lines;
|
|
88
95
|
}
|
|
89
96
|
|
|
@@ -106,19 +113,29 @@ export class SelectList implements Component {
|
|
|
106
113
|
lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
// Add scroll
|
|
110
|
-
if (startIndex > 0 || endIndex < this.#filteredItems.length) {
|
|
111
|
-
|
|
112
|
-
// Truncate if too long for terminal
|
|
113
|
-
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
|
|
116
|
+
// Add scroll/search status when needed
|
|
117
|
+
if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) {
|
|
118
|
+
lines.push(this.#renderStatusLine(width));
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
return lines;
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
handleInput(keyData: string): void {
|
|
120
|
-
if (this.#filteredItems.length === 0) return;
|
|
121
125
|
const kb = getKeybindings();
|
|
126
|
+
// Escape or Ctrl+C
|
|
127
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
128
|
+
if (this.onCancel) {
|
|
129
|
+
this.onCancel();
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.#handleSearchInput(keyData)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (this.#filteredItems.length === 0) return;
|
|
122
139
|
// Up arrow - wrap to bottom when at top
|
|
123
140
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
124
141
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
|
|
@@ -146,12 +163,6 @@ export class SelectList implements Component {
|
|
|
146
163
|
this.onSelect(selectedItem);
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
|
-
// Escape or Ctrl+C
|
|
150
|
-
else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
151
|
-
if (this.onCancel) {
|
|
152
|
-
this.onCancel();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
#renderItem(
|
|
@@ -235,6 +246,71 @@ export class SelectList implements Component {
|
|
|
235
246
|
return sanitizeSingleLine(item.label || item.value);
|
|
236
247
|
}
|
|
237
248
|
|
|
249
|
+
#renderStatusLine(width: number): string {
|
|
250
|
+
const selectedCount = this.#filteredItems.length === 0 ? 0 : this.#selectedIndex + 1;
|
|
251
|
+
const filteredCount = this.#filteredItems.length;
|
|
252
|
+
const count =
|
|
253
|
+
this.#filterQuery.trim() && filteredCount !== this.items.length
|
|
254
|
+
? `${selectedCount}/${filteredCount} of ${this.items.length}`
|
|
255
|
+
: `${selectedCount}/${filteredCount}`;
|
|
256
|
+
const query = sanitizeSingleLine(this.#filterQuery);
|
|
257
|
+
const searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : "";
|
|
258
|
+
const statusText = ` (${count})${searchSuffix}`;
|
|
259
|
+
return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#shouldRenderSearchStatus(): boolean {
|
|
263
|
+
return (
|
|
264
|
+
this.layout.overflowSearch !== false && (this.items.length > this.maxVisible || this.#filterQuery.length > 0)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#canEditSearch(): boolean {
|
|
269
|
+
return this.layout.overflowSearch !== false && this.items.length > this.maxVisible;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#handleSearchInput(keyData: string): boolean {
|
|
273
|
+
if (!this.#canEditSearch()) return false;
|
|
274
|
+
|
|
275
|
+
const kb = getKeybindings();
|
|
276
|
+
if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
|
|
277
|
+
if (this.#filterQuery.length === 0) return false;
|
|
278
|
+
const chars = [...this.#filterQuery];
|
|
279
|
+
chars.pop();
|
|
280
|
+
this.#setFilter(chars.join(""), true);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const printableText = extractPrintableText(keyData);
|
|
285
|
+
if (printableText === undefined) return false;
|
|
286
|
+
if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
|
|
287
|
+
|
|
288
|
+
this.#setFilter(this.#filterQuery + printableText, true);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#setFilter(filter: string, notify: boolean): void {
|
|
293
|
+
this.#filterQuery = filter;
|
|
294
|
+
this.#filteredItems = filter.trim()
|
|
295
|
+
? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item))
|
|
296
|
+
: this.items;
|
|
297
|
+
this.#selectedIndex = 0;
|
|
298
|
+
if (notify) {
|
|
299
|
+
this.#notifySelectionChange();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#getFilterText(item: SelectItem): string {
|
|
304
|
+
let text = `${item.label} ${item.value}`;
|
|
305
|
+
if (item.description) {
|
|
306
|
+
text += ` ${item.description}`;
|
|
307
|
+
}
|
|
308
|
+
if (item.hint) {
|
|
309
|
+
text += ` ${item.hint}`;
|
|
310
|
+
}
|
|
311
|
+
return sanitizeSingleLine(text);
|
|
312
|
+
}
|
|
313
|
+
|
|
238
314
|
#notifySelectionChange(): void {
|
|
239
315
|
const selectedItem = this.#filteredItems[this.#selectedIndex];
|
|
240
316
|
if (selectedItem && this.onSelectionChange) {
|
package/src/terminal.ts
CHANGED
|
@@ -113,6 +113,27 @@ function isWindowsSubsystemForLinux(): boolean {
|
|
|
113
113
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
114
114
|
}
|
|
115
115
|
|
|
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
|
+
|
|
116
137
|
/**
|
|
117
138
|
* Real terminal using process.stdin/stdout
|
|
118
139
|
*/
|
|
@@ -214,9 +235,18 @@ export class ProcessTerminal implements Terminal {
|
|
|
214
235
|
/**
|
|
215
236
|
* Returns true when Windows' active console viewport is at the scrollback tail.
|
|
216
237
|
* POSIX terminals do not expose native scrollback position through a standard API.
|
|
238
|
+
*
|
|
239
|
+
* On native Windows running under Windows Terminal (the default modern
|
|
240
|
+
* host), the `kernel32` probe answers about the ConPTY pseudo-console — not
|
|
241
|
+
* the user-visible WT viewport — so it would always read "at bottom" while
|
|
242
|
+
* the user is scrolled up. Return `undefined` there so the renderer falls
|
|
243
|
+
* back to the POSIX-style deferred-rebuild path: streaming mutations stay
|
|
244
|
+
* non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
|
|
245
|
+
* checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
|
|
246
|
+
* is already pinned to the bottom by the editor keystroke. See #1635.
|
|
217
247
|
*/
|
|
218
248
|
isNativeViewportAtBottom(): boolean | undefined {
|
|
219
|
-
if (
|
|
249
|
+
if (!shouldTrustNativeViewportProbe()) return undefined;
|
|
220
250
|
try {
|
|
221
251
|
const kernel32 = dlopen("kernel32.dll", {
|
|
222
252
|
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
package/src/tui.ts
CHANGED
|
@@ -282,6 +282,7 @@ type RenderIntent =
|
|
|
282
282
|
| { kind: "initial" }
|
|
283
283
|
| { kind: "sessionReplace" }
|
|
284
284
|
| { kind: "historyRebuild" }
|
|
285
|
+
| { kind: "overlayRebuild" }
|
|
285
286
|
| { kind: "viewportRepaint"; appendFrom?: number }
|
|
286
287
|
| { kind: "deferredShrink"; paddedLength: number }
|
|
287
288
|
| { kind: "deferredMutation" }
|
|
@@ -328,7 +329,9 @@ export class TUI extends Container {
|
|
|
328
329
|
#nativeScrollbackDirty = false;
|
|
329
330
|
#fullRedrawCount = 0;
|
|
330
331
|
#clearScrollbackOnNextRender = false;
|
|
332
|
+
#forceViewportRepaintOnNextRender = false;
|
|
331
333
|
#allowUnknownViewportMutationOnNextRender = false;
|
|
334
|
+
#eagerNativeScrollbackRebuild = false;
|
|
332
335
|
#hasEverRendered = false;
|
|
333
336
|
#stopped = false;
|
|
334
337
|
|
|
@@ -376,6 +379,21 @@ export class TUI extends Container {
|
|
|
376
379
|
this.#clearOnShrink = enabled;
|
|
377
380
|
}
|
|
378
381
|
|
|
382
|
+
/**
|
|
383
|
+
* When enabled, live render frames rebuild native scrollback on offscreen and
|
|
384
|
+
* structural changes even when the viewport position is unobservable (POSIX,
|
|
385
|
+
* where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
|
|
386
|
+
* non-destructive repaint. This trades the anti-yank guarantee for a clean,
|
|
387
|
+
* duplicate-free history and is meant for windows where output above the fold
|
|
388
|
+
* is actively re-rendering — e.g. a tool whose result is still streaming and
|
|
389
|
+
* re-laying-out rows that have already scrolled into history. A snap to the tail
|
|
390
|
+
* is acceptable there. A terminal that can report a *known*-scrolled viewport
|
|
391
|
+
* (Windows) still defers; only the unknown case is forced to rebuild.
|
|
392
|
+
*/
|
|
393
|
+
setEagerNativeScrollbackRebuild(enabled: boolean): void {
|
|
394
|
+
this.#eagerNativeScrollbackRebuild = enabled;
|
|
395
|
+
}
|
|
396
|
+
|
|
379
397
|
setFocus(component: Component | null): void {
|
|
380
398
|
// Clear focused flag on old component
|
|
381
399
|
if (isFocusable(this.#focusedComponent)) {
|
|
@@ -675,7 +693,7 @@ export class TUI extends Container {
|
|
|
675
693
|
) {
|
|
676
694
|
return false;
|
|
677
695
|
}
|
|
678
|
-
this.#prepareForcedRender(true);
|
|
696
|
+
this.#prepareForcedRender(true, options?.allowUnknownViewport === true);
|
|
679
697
|
this.#renderRequested = false;
|
|
680
698
|
this.#lastRenderAt = performance.now();
|
|
681
699
|
this.#doRender();
|
|
@@ -683,9 +701,10 @@ export class TUI extends Container {
|
|
|
683
701
|
}
|
|
684
702
|
|
|
685
703
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
686
|
-
|
|
704
|
+
const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
|
|
705
|
+
this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
|
|
687
706
|
if (force) {
|
|
688
|
-
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
707
|
+
this.#prepareForcedRender(options?.clearScrollback === true, allowUnknownViewportMutation);
|
|
689
708
|
this.#renderRequested = true;
|
|
690
709
|
process.nextTick(() => {
|
|
691
710
|
if (this.#stopped || !this.#renderRequested) {
|
|
@@ -702,15 +721,15 @@ export class TUI extends Container {
|
|
|
702
721
|
process.nextTick(() => this.#scheduleRender());
|
|
703
722
|
}
|
|
704
723
|
|
|
705
|
-
#prepareForcedRender(clearScrollback: boolean): void {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
this.#
|
|
713
|
-
this.#
|
|
724
|
+
#prepareForcedRender(clearScrollback: boolean, allowUnknownViewportMutation: boolean): void {
|
|
725
|
+
const geometryChanged =
|
|
726
|
+
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
727
|
+
(this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
|
|
728
|
+
const replayGeometry =
|
|
729
|
+
geometryChanged &&
|
|
730
|
+
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
|
|
731
|
+
this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
|
|
732
|
+
this.#forceViewportRepaintOnNextRender = true;
|
|
714
733
|
if (this.#renderTimer) {
|
|
715
734
|
clearTimeout(this.#renderTimer);
|
|
716
735
|
this.#renderTimer = undefined;
|
|
@@ -1090,23 +1109,27 @@ export class TUI extends Container {
|
|
|
1090
1109
|
* @returns Cursor position { row, col } or null if no marker found
|
|
1091
1110
|
*/
|
|
1092
1111
|
#extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
|
|
1093
|
-
//
|
|
1112
|
+
// Cursor markers are internal sentinels and must never reach the terminal,
|
|
1113
|
+
// even when the focused component is above the visible viewport. Only a
|
|
1114
|
+
// visible marker becomes a hardware cursor target.
|
|
1094
1115
|
const viewportTop = Math.max(0, lines.length - height);
|
|
1095
|
-
|
|
1116
|
+
let cursor: { row: number; col: number } | null = null;
|
|
1117
|
+
for (let row = lines.length - 1; row >= 0; row--) {
|
|
1096
1118
|
const line = lines[row];
|
|
1097
|
-
|
|
1098
|
-
if (markerIndex
|
|
1099
|
-
|
|
1119
|
+
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
1120
|
+
if (markerIndex === -1) continue;
|
|
1121
|
+
if (cursor === null && row >= viewportTop) {
|
|
1100
1122
|
const beforeMarker = line.slice(0, markerIndex);
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
// Strip marker from the line
|
|
1104
|
-
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
|
|
1105
|
-
|
|
1106
|
-
return { row, col };
|
|
1123
|
+
cursor = { row, col: visibleWidth(beforeMarker) };
|
|
1107
1124
|
}
|
|
1125
|
+
let stripped = line;
|
|
1126
|
+
while (markerIndex !== -1) {
|
|
1127
|
+
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
1128
|
+
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
1129
|
+
}
|
|
1130
|
+
lines[row] = stripped;
|
|
1108
1131
|
}
|
|
1109
|
-
return
|
|
1132
|
+
return cursor;
|
|
1110
1133
|
}
|
|
1111
1134
|
|
|
1112
1135
|
/**
|
|
@@ -1140,19 +1163,25 @@ export class TUI extends Container {
|
|
|
1140
1163
|
const height = this.terminal.rows;
|
|
1141
1164
|
|
|
1142
1165
|
// 1. Compose the frame.
|
|
1143
|
-
let
|
|
1166
|
+
let baseLines = this.render(width);
|
|
1167
|
+
let lines = baseLines;
|
|
1144
1168
|
if (this.overlayStack.length > 0) {
|
|
1145
|
-
lines = this.#compositeOverlays(
|
|
1169
|
+
lines = this.#compositeOverlays(baseLines, width, height);
|
|
1146
1170
|
}
|
|
1147
1171
|
const cursorPos = this.#extractCursorPosition(lines, height);
|
|
1148
|
-
lines = this.#applyLineResets(lines);
|
|
1172
|
+
lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
|
|
1173
|
+
if (lines !== baseLines) {
|
|
1174
|
+
this.#extractCursorPosition(baseLines, height);
|
|
1175
|
+
baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
|
|
1176
|
+
}
|
|
1149
1177
|
|
|
1150
1178
|
// 2. Capture transition + pre-render state before any emitter runs.
|
|
1151
1179
|
const prevViewportTop = this.#viewportTopRow;
|
|
1152
1180
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1153
1181
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1154
1182
|
const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height;
|
|
1155
|
-
const allowUnknownViewportMutation =
|
|
1183
|
+
const allowUnknownViewportMutation =
|
|
1184
|
+
this.#allowUnknownViewportMutationOnNextRender || this.#eagerNativeScrollbackRebuild;
|
|
1156
1185
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1157
1186
|
|
|
1158
1187
|
// 3. Classify intent.
|
|
@@ -1192,9 +1221,17 @@ export class TUI extends Container {
|
|
|
1192
1221
|
clearScrollback: !isMultiplexerSession(),
|
|
1193
1222
|
});
|
|
1194
1223
|
return;
|
|
1224
|
+
case "overlayRebuild":
|
|
1225
|
+
this.#clearNativeScrollbackDirty();
|
|
1226
|
+
this.#emitFullPaint(baseLines, width, height, null, {
|
|
1227
|
+
clearViewport: true,
|
|
1228
|
+
clearScrollback: !isMultiplexerSession(),
|
|
1229
|
+
});
|
|
1230
|
+
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1231
|
+
return;
|
|
1195
1232
|
case "viewportRepaint":
|
|
1196
1233
|
if (intent.appendFrom !== undefined) {
|
|
1197
|
-
this.#emitAppendTail(lines, intent.appendFrom, height, prevViewportTop, prevHardwareCursorRow);
|
|
1234
|
+
this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
|
|
1198
1235
|
}
|
|
1199
1236
|
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1200
1237
|
return;
|
|
@@ -1251,23 +1288,31 @@ export class TUI extends Container {
|
|
|
1251
1288
|
// Caller opted into a scrollback wipe via requestRender(true, { clearScrollback: true }).
|
|
1252
1289
|
if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
|
|
1253
1290
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1291
|
+
const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
|
|
1292
|
+
if (this.hasOverlay()) {
|
|
1293
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1294
|
+
if (
|
|
1295
|
+
this.#nativeScrollbackDirty &&
|
|
1296
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1297
|
+
) {
|
|
1298
|
+
return { kind: "overlayRebuild" };
|
|
1299
|
+
}
|
|
1300
|
+
this.#markNativeScrollbackDirty();
|
|
1301
|
+
return { kind: "viewportRepaint" };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1258
1304
|
if (this.#nativeScrollbackDirty && this.#nativeViewportIsAtBottom(this.#readNativeViewportAtBottom())) {
|
|
1259
1305
|
return { kind: "historyRebuild" };
|
|
1260
1306
|
}
|
|
1261
1307
|
|
|
1262
1308
|
const diff = this.#diffLines(newLines);
|
|
1263
1309
|
// Shrink across the viewport boundary: the new transcript would re-expose
|
|
1264
|
-
// rows already committed to native scrollback.
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
// which looks like a jump upward.
|
|
1310
|
+
// rows already committed to native scrollback. Rebuild immediately when the
|
|
1311
|
+
// viewport is known/allowed to be at the tail; otherwise defer the rewrite
|
|
1312
|
+
// and repaint against the previous row count so users scrolled into history
|
|
1313
|
+
// are not yanked. A viewport-only repaint for a bottom-anchored shrink leaves
|
|
1314
|
+
// stale high-water rows in native scrollback and duplicates the new tail above
|
|
1315
|
+
// the viewport.
|
|
1271
1316
|
const naturalViewportTop = Math.max(0, newLines.length - height);
|
|
1272
1317
|
if (
|
|
1273
1318
|
diff.firstChanged !== -1 &&
|
|
@@ -1275,18 +1320,57 @@ export class TUI extends Container {
|
|
|
1275
1320
|
naturalViewportTop < this.#scrollbackHighWater &&
|
|
1276
1321
|
!isMultiplexerSession()
|
|
1277
1322
|
) {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1323
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1324
|
+
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1325
|
+
this.#markNativeScrollbackDirty();
|
|
1326
|
+
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1327
|
+
}
|
|
1328
|
+
// A width change rewraps the whole transcript, so committed scrollback is
|
|
1329
|
+
// mis-wrapped at the old width. Yank is acceptable on an explicit resize, so
|
|
1330
|
+
// rebuild even when the viewport position is unknown (POSIX); the
|
|
1331
|
+
// known-scrolled case already deferred above.
|
|
1332
|
+
if (
|
|
1333
|
+
widthChanged ||
|
|
1334
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1335
|
+
) {
|
|
1283
1336
|
return { kind: "historyRebuild" };
|
|
1284
1337
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1338
|
+
// POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
|
|
1339
|
+
// viewport position fall through here (`canRebuildNativeScrollbackLive` is
|
|
1340
|
+
// false). A destructive rebuild emits `\x1b[3J`, which on modern terminals
|
|
1341
|
+
// resets the viewport to the top of scrollback and yanks a scrolled-up
|
|
1342
|
+
// reader (issue #1635), so it is unsafe while the probe is unavailable.
|
|
1343
|
+
//
|
|
1344
|
+
// When the shrunk transcript now fits entirely in the viewport there is no
|
|
1345
|
+
// new native history to preserve during the live frame: repaint the screen
|
|
1346
|
+
// in place (no `\x1b[3J`) and defer stale-scrollback cleanup to the next
|
|
1347
|
+
// checkpoint rebuild (e.g. prompt submit -> `refreshNativeScrollbackIfDirty`).
|
|
1348
|
+
if (nativeViewportAtBottom === undefined && newLines.length <= height) {
|
|
1349
|
+
this.#markNativeScrollbackDirty();
|
|
1350
|
+
return { kind: "viewportRepaint" };
|
|
1288
1351
|
}
|
|
1289
|
-
|
|
1352
|
+
// The shrunk transcript still overflows the viewport. A plain viewport
|
|
1353
|
+
// repaint would re-emit the rows between the new and old viewport tops on top
|
|
1354
|
+
// of the copies the terminal already kept in native scrollback; `deferredShrink`
|
|
1355
|
+
// pads to the previous row count so no committed row is re-emitted, and the
|
|
1356
|
+
// next checkpoint rebuild cleans up.
|
|
1357
|
+
//
|
|
1358
|
+
// That deferral only carries real content when `newLines.length` reaches the
|
|
1359
|
+
// padded viewport top (`previousLines.length - height`) — otherwise every row
|
|
1360
|
+
// the padded repaint draws is past the end of `newLines` and renders blank,
|
|
1361
|
+
// hiding the prompt until the next checkpoint. This can happen even when
|
|
1362
|
+
// `scrollbackHighWater` is far below `previousLines.length - height`, because
|
|
1363
|
+
// prior unknown-POSIX viewport repaints commit longer logical frames without
|
|
1364
|
+
// moving the native scrollback boundary. For a shrink that large a blank,
|
|
1365
|
+
// uninteractable viewport is the greater evil, so yank with `historyRebuild`.
|
|
1366
|
+
// Real win32 unknown probes defer as scrolled above and never reach this; the
|
|
1367
|
+
// yank only lands on non-win32 hosts whose probe is genuinely unavailable.
|
|
1368
|
+
const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
1369
|
+
if (newLines.length <= paddedViewportTop) {
|
|
1370
|
+
return { kind: "historyRebuild" };
|
|
1371
|
+
}
|
|
1372
|
+
this.#markNativeScrollbackDirty();
|
|
1373
|
+
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1290
1374
|
}
|
|
1291
1375
|
|
|
1292
1376
|
const suppressSuffixScroll = this.#suppressNextSuffixScroll;
|
|
@@ -1297,13 +1381,36 @@ export class TUI extends Container {
|
|
|
1297
1381
|
diff.firstChanged < this.#previousLines.length &&
|
|
1298
1382
|
!isMultiplexerSession()
|
|
1299
1383
|
) {
|
|
1384
|
+
// A checkpoint replay is followed by one frame where transient live chrome
|
|
1385
|
+
// (status/footer rows) may be inserted inside the visible suffix and then
|
|
1386
|
+
// disappear; repaint it in place so it never enters scrollback. If the
|
|
1387
|
+
// insertion grows the overflow boundary, native history would lose rows
|
|
1388
|
+
// while the viewport looks correct, so rebuild instead.
|
|
1389
|
+
const appendedTailStart = this.#findAppendedTailStart(newLines);
|
|
1390
|
+
const overflowBefore = Math.max(0, this.#previousLines.length - height);
|
|
1391
|
+
const overflowAfter = Math.max(0, newLines.length - height);
|
|
1392
|
+
if (
|
|
1393
|
+
appendedTailStart === newLines.length &&
|
|
1394
|
+
diff.firstChanged >= prevViewportTop &&
|
|
1395
|
+
overflowAfter <= overflowBefore
|
|
1396
|
+
) {
|
|
1397
|
+
return { kind: "viewportRepaint" };
|
|
1398
|
+
}
|
|
1399
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1400
|
+
if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1401
|
+
return { kind: "historyRebuild" };
|
|
1402
|
+
}
|
|
1403
|
+
this.#markNativeScrollbackDirty();
|
|
1300
1404
|
return { kind: "viewportRepaint" };
|
|
1301
1405
|
}
|
|
1302
1406
|
|
|
1303
1407
|
if (diff.firstChanged === -1) {
|
|
1304
|
-
// Content unchanged.
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1408
|
+
// Content unchanged. A forced render still needs to refresh the visible
|
|
1409
|
+
// viewport, but it must keep the existing diff basis so later coalesced
|
|
1410
|
+
// content mutations can still update native scrollback correctly.
|
|
1411
|
+
if (forceViewportRepaint) return { kind: "viewportRepaint" };
|
|
1412
|
+
// Width change still alters wrapping geometry; height change shifts the
|
|
1413
|
+
// visible window. Either needs a repaint (outside hostile environments).
|
|
1307
1414
|
if (widthChanged) return { kind: "viewportRepaint" };
|
|
1308
1415
|
if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1309
1416
|
return { kind: "noop" };
|
|
@@ -1328,29 +1435,47 @@ export class TUI extends Container {
|
|
|
1328
1435
|
const contentGrew = newLines.length > this.#previousLines.length;
|
|
1329
1436
|
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1330
1437
|
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
|
|
1438
|
+
if (pureAppend && contentGrew && this.#previousLines.length > height && !isMultiplexerSession()) {
|
|
1439
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1440
|
+
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1441
|
+
this.#markNativeScrollbackDirty();
|
|
1442
|
+
return { kind: "deferredMutation" };
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1331
1445
|
if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
|
|
1332
1446
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1333
1447
|
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1334
1448
|
this.#markNativeScrollbackDirty();
|
|
1335
1449
|
return { kind: "deferredMutation" };
|
|
1336
1450
|
}
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
//
|
|
1451
|
+
// The append-tail path can only scroll a clean pure-tail append over an
|
|
1452
|
+
// offscreen edit into history: the rows it pushes must equal the net
|
|
1453
|
+
// growth, i.e. `#findAppendedTailStart` must land on `previousLines.length`
|
|
1454
|
+
// (`tailAppendCount === addedCount`). Any mismatch is structurally
|
|
1455
|
+
// ambiguous — more added than the matched tail means offscreen rows were
|
|
1456
|
+
// inserted (a collapsed cell expanding); fewer means the previous last
|
|
1457
|
+
// line repeats earlier so the tail is mis-located. Under-counting splices
|
|
1458
|
+
// stale history; over-counting scrolls an extra row and duplicates the
|
|
1459
|
+
// line at the viewport top. Rebuild whenever the replay checkpoint allows.
|
|
1342
1460
|
if (
|
|
1343
1461
|
contentGrew &&
|
|
1344
1462
|
diff.firstChanged < prevViewportTop &&
|
|
1345
|
-
this.#
|
|
1463
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)
|
|
1346
1464
|
) {
|
|
1347
1465
|
const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
|
|
1348
1466
|
const tailAppendCount = newLines.length - appendedTailStart;
|
|
1349
1467
|
const addedCount = newLines.length - this.#previousLines.length;
|
|
1350
|
-
if (addedCount
|
|
1468
|
+
if (addedCount !== tailAppendCount) {
|
|
1351
1469
|
return { kind: "historyRebuild" };
|
|
1352
1470
|
}
|
|
1353
1471
|
}
|
|
1472
|
+
if (
|
|
1473
|
+
newLines.length !== this.#previousLines.length &&
|
|
1474
|
+
this.#scrollbackHighWater > 0 &&
|
|
1475
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1476
|
+
) {
|
|
1477
|
+
return { kind: "historyRebuild" };
|
|
1478
|
+
}
|
|
1354
1479
|
}
|
|
1355
1480
|
|
|
1356
1481
|
// Height changes shift the visible window. Repaint when content didn't
|
|
@@ -1360,6 +1485,17 @@ export class TUI extends Container {
|
|
|
1360
1485
|
return { kind: "viewportRepaint" };
|
|
1361
1486
|
}
|
|
1362
1487
|
|
|
1488
|
+
// A height change that also grew the content into a frame that now fits
|
|
1489
|
+
// entirely on screen cannot use the diff or append-tail emitters below:
|
|
1490
|
+
// both position scrolled rows against the previous viewport top and
|
|
1491
|
+
// hardware cursor row, which the reflow just invalidated, so the appended
|
|
1492
|
+
// tail lands `height`-delta rows too low. With no overflow there is no
|
|
1493
|
+
// native scrollback to preserve, so repaint the viewport at the new
|
|
1494
|
+
// geometry. (Height changes with overflow keep the existing deferral.)
|
|
1495
|
+
if (heightChanged && newLines.length <= height && !isTermuxSession() && !isMultiplexerSession()) {
|
|
1496
|
+
return { kind: "viewportRepaint" };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1363
1499
|
// Configurable shrink-clear: opt-in path that repaints to wipe rows the
|
|
1364
1500
|
// diff path would leave behind.
|
|
1365
1501
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
@@ -1371,11 +1507,33 @@ export class TUI extends Container {
|
|
|
1371
1507
|
return { kind: "shrink" };
|
|
1372
1508
|
}
|
|
1373
1509
|
|
|
1374
|
-
// Offscreen edit:
|
|
1375
|
-
//
|
|
1510
|
+
// Offscreen edit: repainting only the viewport leaves native history stale
|
|
1511
|
+
// while the user is bottom-anchored. Rebuild whenever replay is safe. If
|
|
1512
|
+
// replay is not safe, keep the viewport stable, mark history dirty, and only
|
|
1513
|
+
// scroll a clean appended tail so newly streamed rows remain reachable until
|
|
1514
|
+
// the next checkpoint rebuild.
|
|
1376
1515
|
if (diff.firstChanged < prevViewportTop) {
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1516
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1517
|
+
const cleanTailAppend =
|
|
1518
|
+
diff.appendedLines && this.#findAppendedTailStart(newLines) === this.#previousLines.length;
|
|
1519
|
+
if (
|
|
1520
|
+
!isMultiplexerSession() &&
|
|
1521
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1522
|
+
) {
|
|
1523
|
+
return { kind: "historyRebuild" };
|
|
1524
|
+
}
|
|
1525
|
+
this.#markNativeScrollbackDirty();
|
|
1526
|
+
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (forceViewportRepaint) {
|
|
1530
|
+
if (isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1531
|
+
if (pureAppend && contentGrew && this.#previousLines.length >= height) {
|
|
1532
|
+
return { kind: "viewportRepaint", appendFrom: this.#previousLines.length };
|
|
1533
|
+
}
|
|
1534
|
+
if (newLines.length === this.#previousLines.length && diff.firstChanged >= prevViewportTop) {
|
|
1535
|
+
return { kind: "viewportRepaint" };
|
|
1536
|
+
}
|
|
1379
1537
|
}
|
|
1380
1538
|
|
|
1381
1539
|
return {
|
|
@@ -1477,6 +1635,30 @@ export class TUI extends Container {
|
|
|
1477
1635
|
);
|
|
1478
1636
|
}
|
|
1479
1637
|
|
|
1638
|
+
/**
|
|
1639
|
+
* Live-frame counterpart to {@link #canReplayNativeScrollbackAtCheckpoint}.
|
|
1640
|
+
* Decides whether a destructive native scrollback rebuild
|
|
1641
|
+
* (`historyRebuild`/`overlayRebuild`, which clear scrollback and snap the
|
|
1642
|
+
* viewport to the tail) is safe to emit *during ordinary rendering*. POSIX
|
|
1643
|
+
* terminals cannot report whether the user has scrolled up
|
|
1644
|
+
* (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
|
|
1645
|
+
* treated as unsafe: defer to a non-destructive viewport repaint, mark
|
|
1646
|
+
* scrollback dirty, and reconcile history at the next explicit checkpoint
|
|
1647
|
+
* ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the
|
|
1648
|
+
* editor keystroke has already pinned the terminal to the bottom. Without
|
|
1649
|
+
* this, every offscreen transcript edit while streaming wiped scrollback and
|
|
1650
|
+
* yanked a scrolled-up reader back down. `allowUnknownViewportMutation`
|
|
1651
|
+
* (autocomplete/IME) opts directly user-driven frames back into the rebuild.
|
|
1652
|
+
* Unlike the checkpoint predicate this carries no `process.platform`
|
|
1653
|
+
* optimism — resize and checkpoint replays keep using that one.
|
|
1654
|
+
*/
|
|
1655
|
+
#canRebuildNativeScrollbackLive(
|
|
1656
|
+
nativeViewportAtBottom: boolean | undefined,
|
|
1657
|
+
allowUnknownViewportMutation: boolean,
|
|
1658
|
+
): boolean {
|
|
1659
|
+
return nativeViewportAtBottom === true || (nativeViewportAtBottom === undefined && allowUnknownViewportMutation);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1480
1662
|
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
|
1481
1663
|
if (lines.length >= paddedLength) return lines;
|
|
1482
1664
|
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
|
|
@@ -1488,6 +1670,13 @@ export class TUI extends Container {
|
|
|
1488
1670
|
* `truncateToWidth` drops the trailing bytes appended by
|
|
1489
1671
|
* {@link #applyLineResets}.
|
|
1490
1672
|
*/
|
|
1673
|
+
#fitLinesToWidth(lines: string[], width: number): string[] {
|
|
1674
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1675
|
+
lines[i] = this.#fitLineToWidth(lines[i], width);
|
|
1676
|
+
}
|
|
1677
|
+
return lines;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1491
1680
|
#fitLineToWidth(line: string, width: number): string {
|
|
1492
1681
|
if (TERMINAL.isImageLine(line)) return line;
|
|
1493
1682
|
if (visibleWidth(line) <= width) return line;
|
|
@@ -1501,6 +1690,7 @@ export class TUI extends Container {
|
|
|
1501
1690
|
*/
|
|
1502
1691
|
#commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
|
|
1503
1692
|
this.#previousLines = lines;
|
|
1693
|
+
this.#forceViewportRepaintOnNextRender = false;
|
|
1504
1694
|
this.#previousWidth = width;
|
|
1505
1695
|
this.#previousHeight = height;
|
|
1506
1696
|
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
@@ -1526,7 +1716,7 @@ export class TUI extends Container {
|
|
|
1526
1716
|
}
|
|
1527
1717
|
for (let i = 0; i < lines.length; i++) {
|
|
1528
1718
|
if (i > 0) buffer += "\r\n";
|
|
1529
|
-
buffer += lines[i];
|
|
1719
|
+
buffer += this.#fitLineToWidth(lines[i], width);
|
|
1530
1720
|
}
|
|
1531
1721
|
const finalRow = Math.max(0, lines.length - 1);
|
|
1532
1722
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
@@ -1592,6 +1782,7 @@ export class TUI extends Container {
|
|
|
1592
1782
|
lines: string[],
|
|
1593
1783
|
start: number,
|
|
1594
1784
|
height: number,
|
|
1785
|
+
width: number,
|
|
1595
1786
|
prevViewportTop: number,
|
|
1596
1787
|
prevHardwareCursorRow: number,
|
|
1597
1788
|
): void {
|
|
@@ -1606,7 +1797,7 @@ export class TUI extends Container {
|
|
|
1606
1797
|
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
1607
1798
|
for (let i = start; i < lines.length; i++) {
|
|
1608
1799
|
buffer += "\r\n";
|
|
1609
|
-
buffer += lines[i];
|
|
1800
|
+
buffer += this.#fitLineToWidth(lines[i], width);
|
|
1610
1801
|
}
|
|
1611
1802
|
buffer += PAINT_END;
|
|
1612
1803
|
this.terminal.write(buffer);
|