@oh-my-pi/pi-tui 15.9.5 → 15.9.67
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 +19 -0
- package/dist/types/components/box.d.ts +1 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal.d.ts +5 -0
- package/dist/types/tui.d.ts +2 -2
- package/package.json +3 -3
- package/src/components/box.ts +6 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +17 -11
- package/src/components/settings-list.ts +36 -38
- package/src/index.ts +1 -0
- package/src/terminal.ts +6 -0
- package/src/tui.ts +175 -42
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.9.67] - 2026-06-06
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
|
|
9
|
+
- Added `ScrollView`, a fixed-height viewport component for pre-rendered lines with optional right-edge scrollbars and imperative scroll/page controls.
|
|
10
|
+
- Added optional `Terminal.hasEagerEraseScrollbackRisk()` so custom/test terminal implementations can override the global ED3-risk profile without mutating the shared `TERMINAL` object.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Changed `SelectList` to render its visible window through `ScrollView`, replacing the `(N/M)` text scroll indicator with a uniform right-edge scrollbar (the type-to-search hint line is preserved).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed unknown-viewport deferred renders freezing bottom-anchored live chrome; deferred history mutations can now repaint only the active-grid bottom row with relative cursor movement, so spinner/status tails keep advancing without rewriting rows a scrolled reader can still see.
|
|
19
|
+
- Fixed autocomplete popups freezing live repaint on ED3-risk macOS/POSIX terminals with unknown native viewport position; direct autocomplete shrink frames now repaint the live viewport without zero-byte deferral and preserve the old bottom anchor when padding can clear stale popup rows without duplicating committed scrollback.
|
|
20
|
+
- Fixed focused Up/Down navigation on ED3-risk macOS/POSIX terminals replaying the whole transcript after dirty foreground-stream renders; selector/editor frames now repaint non-destructively instead of emitting `CSI 3 J` on every arrow-key move ([#1962](https://github.com/can1357/oh-my-pi/issues/1962)).
|
|
21
|
+
- Fixed tmux (and screen/zellij) pane scrollback losing the head of a long streamed assistant reply once it grew past the visible pane, and stranding the chrome/footer in pane history after a later collapse — producing the "repeating chunks and missing sections" reporters saw when scrolling back through tmux pane history ([#1974](https://github.com/can1357/oh-my-pi/issues/1974)). The renderer's foreground-streaming cap-to-viewport branch (introduced in 15.9.2 for ED3-risk hosts that can checkpoint-rebuild later) also activated inside multiplexers, where checkpoint reconcile is a no-op (`refreshNativeScrollbackIfDirty` short-circuits because `\x1b[3J` cannot erase pane history). Every streaming frame clipped `lines` to the visible tail and reset `#scrollbackHighWater` to 0, so any row that scrolled above the viewport top was committed nowhere — pane history stayed empty until streaming ended. Meanwhile `#planLiveRegionPinnedRender` was explicitly disabled for multiplexers, but its `#emitLiveRegionPinnedRepaint` is built from the exact primitives tmux accepts (relative cursor moves, per-line `\x1b[2K`, `\r\n` to scroll the sealed prefix past the viewport bottom) and never emits `\x1b[2J`/`\x1b[3J`. The pinned planner now runs in multiplexers too, the cap branch skips them, and the diff/append path commits incrementally into pane history; the actively-mutating live tail stays in the visible viewport only.
|
|
22
|
+
|
|
5
23
|
## [15.9.5] - 2026-06-05
|
|
6
24
|
|
|
7
25
|
### Changed
|
|
@@ -13,6 +31,7 @@
|
|
|
13
31
|
- Fixed ED3-risk foreground streaming dropping the scrolled-off head of an append-only live block that alone overflows the viewport (a long streamed assistant reply). The live-region pin again committed native scrollback only up to the live-region start, so once the live block grew past the viewport its earlier rows scrolled above the viewport top but were committed nowhere and repainted nowhere — they vanished, leaving the reply looking like a ~viewport-tall circular buffer. The `NativeScrollbackLiveRegion` seam now also reports an optional append-only `getNativeScrollbackCommitSafeEnd`, and the pinned commit boundary is the deeper of the sealed start and that append-only end: rows in `[liveRegionStart, commitSafeEnd)` above the viewport top commit to scrollback, while volatile live blocks (tool previews that collapse) omit the boundary and keep their mutable rows deferred — preserving the pending-box-above-running-box fix.
|
|
14
32
|
|
|
15
33
|
## [15.9.4] - 2026-06-05
|
|
34
|
+
|
|
16
35
|
### Added
|
|
17
36
|
|
|
18
37
|
- Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
|
|
@@ -9,6 +9,7 @@ export declare class Box implements Component {
|
|
|
9
9
|
addChild(component: Component): void;
|
|
10
10
|
removeChild(component: Component): void;
|
|
11
11
|
clear(): void;
|
|
12
|
+
setPaddingX(paddingX: number): void;
|
|
12
13
|
setBgFn(bgFn?: (text: string) => string): void;
|
|
13
14
|
invalidate(): void;
|
|
14
15
|
render(width: number): string[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
type ScrollbarMode = "auto" | "always" | "never";
|
|
3
|
+
export interface ScrollViewTheme {
|
|
4
|
+
track?: (text: string) => string;
|
|
5
|
+
thumb?: (text: string) => string;
|
|
6
|
+
}
|
|
7
|
+
export interface ScrollViewOptions {
|
|
8
|
+
height: number;
|
|
9
|
+
/** Defaults to "auto". "auto" reserves a scrollbar column only when content overflows. */
|
|
10
|
+
scrollbar?: ScrollbarMode | boolean;
|
|
11
|
+
/** Logical row count for pre-windowed line slices. Defaults to lines.length. */
|
|
12
|
+
totalRows?: number;
|
|
13
|
+
theme?: ScrollViewTheme;
|
|
14
|
+
trackChar?: string;
|
|
15
|
+
thumbChar?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fixed-height viewport over pre-rendered lines, with optional right-edge scrollbar.
|
|
19
|
+
*
|
|
20
|
+
* ScrollView owns only the row offset. Callers remain responsible for producing
|
|
21
|
+
* already-wrapped logical lines appropriate for the current render width.
|
|
22
|
+
*/
|
|
23
|
+
export declare class ScrollView implements Component {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(lines: readonly string[], options: ScrollViewOptions);
|
|
26
|
+
setLines(lines: readonly string[]): void;
|
|
27
|
+
setTotalRows(totalRows: number | undefined): void;
|
|
28
|
+
setHeight(height: number): void;
|
|
29
|
+
setScrollbar(scrollbar: ScrollViewOptions["scrollbar"]): void;
|
|
30
|
+
getScrollOffset(): number;
|
|
31
|
+
getMaxScrollOffset(): number;
|
|
32
|
+
setScrollOffset(offset: number): void;
|
|
33
|
+
scroll(delta: number): void;
|
|
34
|
+
page(delta: number): void;
|
|
35
|
+
scrollToTop(): void;
|
|
36
|
+
scrollToBottom(): void;
|
|
37
|
+
invalidate(): void;
|
|
38
|
+
render(width: number): string[];
|
|
39
|
+
}
|
|
40
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./components/image";
|
|
|
6
6
|
export * from "./components/input";
|
|
7
7
|
export * from "./components/loader";
|
|
8
8
|
export * from "./components/markdown";
|
|
9
|
+
export * from "./components/scroll-view";
|
|
9
10
|
export * from "./components/select-list";
|
|
10
11
|
export * from "./components/settings-list";
|
|
11
12
|
export * from "./components/spacer";
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -58,6 +58,11 @@ export interface Terminal {
|
|
|
58
58
|
* (xterm.js-backed) implement this with a real answer.
|
|
59
59
|
*/
|
|
60
60
|
isNativeViewportAtBottom?(): boolean | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Override the global terminal-profile ED3 risk decision for custom/test
|
|
63
|
+
* terminals. `undefined` falls back to the resolved `TERMINAL` profile.
|
|
64
|
+
*/
|
|
65
|
+
hasEagerEraseScrollbackRisk?(): boolean | undefined;
|
|
61
66
|
/**
|
|
62
67
|
* Register a callback for terminal appearance (dark/light) changes.
|
|
63
68
|
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -231,8 +231,8 @@ export declare class TUI extends Container {
|
|
|
231
231
|
* (the viewport is never observable there and ConPTY hosts erase host
|
|
232
232
|
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
233
233
|
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
234
|
-
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
235
|
-
*
|
|
234
|
+
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
235
|
+
* rebuilds are unaffected.
|
|
236
236
|
*
|
|
237
237
|
* Disabling stays active through one already-requested frame: the event batch
|
|
238
238
|
* that ends a foreground stream both removes its UI rows (loader/status
|
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.9.
|
|
4
|
+
"version": "15.9.67",
|
|
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.9.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.9.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.9.67",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.9.67",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -42,6 +42,12 @@ export class Box implements Component {
|
|
|
42
42
|
this.#invalidateCache();
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
setPaddingX(paddingX: number): void {
|
|
46
|
+
if (this.#paddingX === paddingX) return;
|
|
47
|
+
this.#paddingX = paddingX;
|
|
48
|
+
this.#invalidateCache();
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
setBgFn(bgFn?: (text: string) => string): void {
|
|
46
52
|
this.#bgFn = bgFn;
|
|
47
53
|
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { Ellipsis, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TRACK = "│";
|
|
5
|
+
const DEFAULT_THUMB = "█";
|
|
6
|
+
|
|
7
|
+
type ScrollbarMode = "auto" | "always" | "never";
|
|
8
|
+
|
|
9
|
+
export interface ScrollViewTheme {
|
|
10
|
+
track?: (text: string) => string;
|
|
11
|
+
thumb?: (text: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ScrollViewOptions {
|
|
15
|
+
height: number;
|
|
16
|
+
/** Defaults to "auto". "auto" reserves a scrollbar column only when content overflows. */
|
|
17
|
+
scrollbar?: ScrollbarMode | boolean;
|
|
18
|
+
/** Logical row count for pre-windowed line slices. Defaults to lines.length. */
|
|
19
|
+
totalRows?: number;
|
|
20
|
+
theme?: ScrollViewTheme;
|
|
21
|
+
trackChar?: string;
|
|
22
|
+
thumbChar?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeScrollbarMode(scrollbar: ScrollViewOptions["scrollbar"]): ScrollbarMode {
|
|
26
|
+
if (scrollbar === true) return "auto";
|
|
27
|
+
if (scrollbar === false) return "never";
|
|
28
|
+
return scrollbar ?? "auto";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function firstCellGlyph(value: string, fallback: string): string {
|
|
32
|
+
const glyph = Array.from(value)[0] ?? fallback;
|
|
33
|
+
return visibleWidth(glyph) === 1 ? glyph : fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fixed-height viewport over pre-rendered lines, with optional right-edge scrollbar.
|
|
38
|
+
*
|
|
39
|
+
* ScrollView owns only the row offset. Callers remain responsible for producing
|
|
40
|
+
* already-wrapped logical lines appropriate for the current render width.
|
|
41
|
+
*/
|
|
42
|
+
export class ScrollView implements Component {
|
|
43
|
+
#lines: string[];
|
|
44
|
+
#height: number;
|
|
45
|
+
#scrollOffset = 0;
|
|
46
|
+
#totalRows: number | undefined;
|
|
47
|
+
#scrollbar: ScrollbarMode;
|
|
48
|
+
#theme: Required<ScrollViewTheme>;
|
|
49
|
+
#trackChar: string;
|
|
50
|
+
#thumbChar: string;
|
|
51
|
+
|
|
52
|
+
constructor(lines: readonly string[], options: ScrollViewOptions) {
|
|
53
|
+
this.#lines = [...lines];
|
|
54
|
+
this.#height = Number.isFinite(options.height) ? Math.max(0, Math.trunc(options.height)) : 0;
|
|
55
|
+
this.#totalRows = options.totalRows === undefined ? undefined : Math.max(0, Math.trunc(options.totalRows));
|
|
56
|
+
this.#scrollbar = normalizeScrollbarMode(options.scrollbar);
|
|
57
|
+
this.#theme = {
|
|
58
|
+
track: options.theme?.track ?? (text => text),
|
|
59
|
+
thumb: options.theme?.thumb ?? (text => text),
|
|
60
|
+
};
|
|
61
|
+
this.#trackChar = firstCellGlyph(options.trackChar ?? DEFAULT_TRACK, DEFAULT_TRACK);
|
|
62
|
+
this.#thumbChar = firstCellGlyph(options.thumbChar ?? DEFAULT_THUMB, DEFAULT_THUMB);
|
|
63
|
+
this.#clampScrollOffset();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setLines(lines: readonly string[]): void {
|
|
67
|
+
this.#lines = [...lines];
|
|
68
|
+
this.#clampScrollOffset();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setTotalRows(totalRows: number | undefined): void {
|
|
72
|
+
this.#totalRows = totalRows === undefined ? undefined : Math.max(0, Math.trunc(totalRows));
|
|
73
|
+
this.#clampScrollOffset();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setHeight(height: number): void {
|
|
77
|
+
this.#height = Number.isFinite(height) ? Math.max(0, Math.trunc(height)) : 0;
|
|
78
|
+
this.#clampScrollOffset();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setScrollbar(scrollbar: ScrollViewOptions["scrollbar"]): void {
|
|
82
|
+
this.#scrollbar = normalizeScrollbarMode(scrollbar);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getScrollOffset(): number {
|
|
86
|
+
return this.#scrollOffset;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getMaxScrollOffset(): number {
|
|
90
|
+
const rowCount = this.#totalRows ?? this.#lines.length;
|
|
91
|
+
return Math.max(0, rowCount - this.#height);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setScrollOffset(offset: number): void {
|
|
95
|
+
this.#scrollOffset = Number.isFinite(offset) ? Math.trunc(offset) : 0;
|
|
96
|
+
this.#clampScrollOffset();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
scroll(delta: number): void {
|
|
100
|
+
this.setScrollOffset(this.#scrollOffset + (Number.isFinite(delta) ? Math.trunc(delta) : 0));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
page(delta: number): void {
|
|
104
|
+
const step = Math.max(1, this.#height - 1);
|
|
105
|
+
this.scroll(step * (Number.isFinite(delta) ? Math.trunc(delta) : 0));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
scrollToTop(): void {
|
|
109
|
+
this.#scrollOffset = 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
scrollToBottom(): void {
|
|
113
|
+
this.#scrollOffset = this.getMaxScrollOffset();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
invalidate(): void {
|
|
117
|
+
// No cached layout to invalidate.
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
render(width: number): string[] {
|
|
121
|
+
this.#clampScrollOffset();
|
|
122
|
+
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
|
|
123
|
+
if (this.#height === 0) return [];
|
|
124
|
+
const showScrollbar = safeWidth > 0 && this.#shouldRenderScrollbar();
|
|
125
|
+
const contentWidth = Math.max(0, safeWidth - (showScrollbar ? 1 : 0));
|
|
126
|
+
const thumb = showScrollbar ? this.#thumbRange() : undefined;
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
for (let row = 0; row < this.#height; row++) {
|
|
129
|
+
const sourceIndex = this.#totalRows === undefined ? this.#scrollOffset + row : row;
|
|
130
|
+
const source = this.#lines[sourceIndex] ?? "";
|
|
131
|
+
const truncated = truncateToWidth(replaceTabs(source), contentWidth, Ellipsis.Unicode);
|
|
132
|
+
if (!showScrollbar) {
|
|
133
|
+
lines.push(truncated);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const content = `${truncated}${" ".repeat(Math.max(0, contentWidth - visibleWidth(truncated)))}`;
|
|
137
|
+
const barGlyph = thumb && row >= thumb.start && row < thumb.end ? this.#thumbChar : this.#trackChar;
|
|
138
|
+
const styledBar =
|
|
139
|
+
thumb && row >= thumb.start && row < thumb.end ? this.#theme.thumb(barGlyph) : this.#theme.track(barGlyph);
|
|
140
|
+
lines.push(`${content}${styledBar}`);
|
|
141
|
+
}
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#clampScrollOffset(): void {
|
|
146
|
+
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, this.getMaxScrollOffset()));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#shouldRenderScrollbar(): boolean {
|
|
150
|
+
if (this.#height <= 0) return false;
|
|
151
|
+
if (this.#scrollbar === "never") return false;
|
|
152
|
+
if (this.#scrollbar === "always") return true;
|
|
153
|
+
return (this.#totalRows ?? this.#lines.length) > this.#height;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#thumbRange(): { start: number; end: number } {
|
|
157
|
+
if (this.#height <= 0) return { start: 0, end: 0 };
|
|
158
|
+
const rowCount = this.#totalRows ?? this.#lines.length;
|
|
159
|
+
if (rowCount <= this.#height) return { start: 0, end: this.#height };
|
|
160
|
+
const thumbSize = Math.max(1, Math.min(Math.floor((this.#height * this.#height) / rowCount), this.#height));
|
|
161
|
+
const travel = this.#height - thumbSize;
|
|
162
|
+
const maxOffset = this.getMaxScrollOffset();
|
|
163
|
+
const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
|
|
164
|
+
return { start, end: start + thumbSize };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -4,6 +4,7 @@ import { extractPrintableText } from "../keys";
|
|
|
4
4
|
import type { SymbolTheme } from "../symbols";
|
|
5
5
|
import type { Component } from "../tui";
|
|
6
6
|
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
7
|
+
import { ScrollView } from "./scroll-view";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
|
9
10
|
const PRIMARY_COLUMN_GAP = 2;
|
|
@@ -104,17 +105,29 @@ export class SelectList implements Component {
|
|
|
104
105
|
const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
|
|
105
106
|
|
|
106
107
|
// Render visible items
|
|
108
|
+
const overflow = this.#filteredItems.length > this.maxVisible;
|
|
109
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
110
|
+
const rows: string[] = [];
|
|
107
111
|
for (let i = startIndex; i < endIndex; i++) {
|
|
108
112
|
const item = this.#filteredItems[i];
|
|
109
113
|
if (!item) continue;
|
|
110
114
|
|
|
111
115
|
const isSelected = i === this.#selectedIndex;
|
|
112
116
|
const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
|
|
113
|
-
|
|
117
|
+
rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
const sv = new ScrollView(rows, {
|
|
121
|
+
height: rows.length,
|
|
122
|
+
scrollbar: "auto",
|
|
123
|
+
totalRows: this.#filteredItems.length,
|
|
124
|
+
theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
|
|
125
|
+
});
|
|
126
|
+
sv.setScrollOffset(startIndex);
|
|
127
|
+
lines.push(...sv.render(width));
|
|
128
|
+
|
|
129
|
+
// Add search status when relevant (scrollbar now indicates overflow)
|
|
130
|
+
if (showSearchStatus) {
|
|
118
131
|
lines.push(this.#renderStatusLine(width));
|
|
119
132
|
}
|
|
120
133
|
|
|
@@ -247,15 +260,8 @@ export class SelectList implements Component {
|
|
|
247
260
|
}
|
|
248
261
|
|
|
249
262
|
#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
263
|
const query = sanitizeSingleLine(this.#filterQuery);
|
|
257
|
-
const
|
|
258
|
-
const statusText = ` (${count})${searchSuffix}`;
|
|
264
|
+
const statusText = query ? ` Search: ${query}` : " Type to search";
|
|
259
265
|
return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
|
|
260
266
|
}
|
|
261
267
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getKeybindings } from "../keybindings";
|
|
2
2
|
import type { Component } from "../tui";
|
|
3
3
|
import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
|
+
import { ScrollView } from "./scroll-view";
|
|
4
5
|
|
|
5
6
|
export interface SettingItem {
|
|
6
7
|
/** Unique identifier for this setting */
|
|
@@ -90,6 +91,22 @@ export class SettingsList implements Component {
|
|
|
90
91
|
return this.#renderMainList(width);
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
#renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number): string {
|
|
95
|
+
const isSelected = index === this.#selectedIndex;
|
|
96
|
+
const prefix = isSelected ? this.#theme.cursor : " ";
|
|
97
|
+
const prefixWidth = visibleWidth(prefix);
|
|
98
|
+
const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
99
|
+
const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
|
|
100
|
+
const separator = " ";
|
|
101
|
+
const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
|
|
102
|
+
const valueText = this.#theme.value(
|
|
103
|
+
truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
|
|
104
|
+
isSelected,
|
|
105
|
+
item.changed === true,
|
|
106
|
+
);
|
|
107
|
+
return truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
#renderMainList(width: number): string[] {
|
|
94
111
|
const lines: string[] = [];
|
|
95
112
|
|
|
@@ -98,48 +115,29 @@ export class SettingsList implements Component {
|
|
|
98
115
|
return lines;
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
|
|
118
|
+
const viewportHeight = Math.min(this.#maxVisible, this.#items.length);
|
|
102
119
|
const startIndex = Math.max(
|
|
103
120
|
0,
|
|
104
|
-
Math.min(this.#selectedIndex - Math.floor(
|
|
121
|
+
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#items.length - viewportHeight),
|
|
105
122
|
);
|
|
106
|
-
const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length);
|
|
107
|
-
|
|
108
|
-
// Calculate max label width for alignment
|
|
109
123
|
const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const valueMaxWidth = width - usedWidth - 2;
|
|
128
|
-
|
|
129
|
-
const valueText = this.#theme.value(
|
|
130
|
-
truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
|
|
131
|
-
isSelected,
|
|
132
|
-
item.changed === true,
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Add scroll indicator if needed
|
|
139
|
-
if (startIndex > 0 || endIndex < this.#items.length) {
|
|
140
|
-
const scrollText = ` (${this.#selectedIndex + 1}/${this.#items.length})`;
|
|
141
|
-
lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
|
|
142
|
-
}
|
|
124
|
+
const itemRowsOverflow = this.#items.length > viewportHeight;
|
|
125
|
+
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
|
|
126
|
+
const visibleItems = this.#items.slice(startIndex, startIndex + viewportHeight);
|
|
127
|
+
const itemRows = visibleItems.map((item, index) =>
|
|
128
|
+
this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth),
|
|
129
|
+
);
|
|
130
|
+
const scrollView = new ScrollView(itemRows, {
|
|
131
|
+
height: viewportHeight,
|
|
132
|
+
scrollbar: "auto",
|
|
133
|
+
totalRows: this.#items.length,
|
|
134
|
+
theme: {
|
|
135
|
+
track: text => this.#theme.hint(text),
|
|
136
|
+
thumb: text => this.#theme.label(text, true, false),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
scrollView.setScrollOffset(startIndex);
|
|
140
|
+
lines.push(...scrollView.render(width));
|
|
143
141
|
|
|
144
142
|
// Add description for selected item
|
|
145
143
|
const selectedItem = this.#items[this.#selectedIndex];
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./components/image";
|
|
|
10
10
|
export * from "./components/input";
|
|
11
11
|
export * from "./components/loader";
|
|
12
12
|
export * from "./components/markdown";
|
|
13
|
+
export * from "./components/scroll-view";
|
|
13
14
|
export * from "./components/select-list";
|
|
14
15
|
export * from "./components/settings-list";
|
|
15
16
|
export * from "./components/spacer";
|
package/src/terminal.ts
CHANGED
|
@@ -129,6 +129,12 @@ export interface Terminal {
|
|
|
129
129
|
*/
|
|
130
130
|
isNativeViewportAtBottom?(): boolean | undefined;
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Override the global terminal-profile ED3 risk decision for custom/test
|
|
134
|
+
* terminals. `undefined` falls back to the resolved `TERMINAL` profile.
|
|
135
|
+
*/
|
|
136
|
+
hasEagerEraseScrollbackRisk?(): boolean | undefined;
|
|
137
|
+
|
|
132
138
|
/**
|
|
133
139
|
* Register a callback for terminal appearance (dark/light) changes.
|
|
134
140
|
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
|
package/src/tui.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal TUI implementation with differential rendering
|
|
2
|
+
* Minimal TUI implementation with differential rendering.
|
|
3
|
+
*
|
|
4
|
+
* Before changing the render planner, native-scrollback bookkeeping, capability
|
|
5
|
+
* detection, or width math, read `docs/tui-core-renderer.md`: it documents the
|
|
6
|
+
* failure modes (yank / corruption / flash / width crashes) and the invariants
|
|
7
|
+
* this engine must not violate. The short version: the renderer cannot observe
|
|
8
|
+
* the terminal's scroll position on most hosts, so ED3 (`CSI 3 J`) is confined
|
|
9
|
+
* to the destructive `clearScrollback` path, an unobservable viewport probe is
|
|
10
|
+
* never trusted for passive streaming, and the hot path clamps over-wide lines
|
|
11
|
+
* instead of throwing.
|
|
3
12
|
*/
|
|
4
13
|
import * as fs from "node:fs";
|
|
5
14
|
import * as path from "node:path";
|
|
@@ -357,6 +366,10 @@ export class Container implements Component {
|
|
|
357
366
|
* - `deferredShrink`: pure content shrink would re-expose rows already in
|
|
358
367
|
* native history. Keep row indices stable with blank tail padding, repaint
|
|
359
368
|
* only the viewport, and defer the real shorter replay to a checkpoint.
|
|
369
|
+
* - `deferredTailRepaint`: a deferred history mutation also changed the active
|
|
370
|
+
* grid's bottom row; repaint only that row relative to the tracked hardware
|
|
371
|
+
* cursor so a bottom-anchored spinner can advance without rewriting rows that
|
|
372
|
+
* a slightly-scrolled reader can still see.
|
|
360
373
|
* - `deferredMutation`: a row-inserting edit would reindex native scrollback
|
|
361
374
|
* while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
|
|
362
375
|
* - `shrink`: trailing rows were dropped — clear extras inline.
|
|
@@ -371,6 +384,7 @@ type RenderIntent =
|
|
|
371
384
|
| { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
|
|
372
385
|
| { kind: "viewportRepaint"; appendFrom?: number }
|
|
373
386
|
| { kind: "deferredShrink"; paddedLength: number }
|
|
387
|
+
| { kind: "deferredTailRepaint"; row: number; line: string }
|
|
374
388
|
| { kind: "deferredMutation" }
|
|
375
389
|
| { kind: "shrink" }
|
|
376
390
|
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
|
|
@@ -421,6 +435,7 @@ export class TUI extends Container {
|
|
|
421
435
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
422
436
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
423
437
|
#nativeScrollbackDirty = false;
|
|
438
|
+
#deferredTailLine: string | undefined;
|
|
424
439
|
// Highest `#maxLinesRendered` reached during a foreground tool turn while
|
|
425
440
|
// intermediate frames were prevented from committing to terminal scrollback.
|
|
426
441
|
// Used after the tool finishes to push the settled content into scrollback
|
|
@@ -569,8 +584,8 @@ export class TUI extends Container {
|
|
|
569
584
|
* (the viewport is never observable there and ConPTY hosts erase host
|
|
570
585
|
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
571
586
|
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
572
|
-
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
573
|
-
*
|
|
587
|
+
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
588
|
+
* rebuilds are unaffected.
|
|
574
589
|
*
|
|
575
590
|
* Disabling stays active through one already-requested frame: the event batch
|
|
576
591
|
* that ends a foreground stream both removes its UI rows (loader/status
|
|
@@ -591,7 +606,7 @@ export class TUI extends Container {
|
|
|
591
606
|
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
|
592
607
|
return;
|
|
593
608
|
}
|
|
594
|
-
if (
|
|
609
|
+
if (this.#hasEagerEraseScrollbackRisk()) {
|
|
595
610
|
this.#streamingHighWater = 0;
|
|
596
611
|
this.#markNativeScrollbackDirty();
|
|
597
612
|
}
|
|
@@ -1463,7 +1478,7 @@ export class TUI extends Container {
|
|
|
1463
1478
|
const heightChanged =
|
|
1464
1479
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
1465
1480
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1466
|
-
const eagerEraseScrollbackRisk =
|
|
1481
|
+
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1467
1482
|
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1468
1483
|
const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
|
|
1469
1484
|
const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
|
|
@@ -1528,6 +1543,7 @@ export class TUI extends Container {
|
|
|
1528
1543
|
} else if (
|
|
1529
1544
|
!explicitReconcile &&
|
|
1530
1545
|
nativeViewportAtBottom !== true &&
|
|
1546
|
+
!isMultiplexerSession() &&
|
|
1531
1547
|
(intent.kind === "sessionReplace" ||
|
|
1532
1548
|
intent.kind === "historyRebuild" ||
|
|
1533
1549
|
intent.kind === "overlayRebuild" ||
|
|
@@ -1535,6 +1551,13 @@ export class TUI extends Container {
|
|
|
1535
1551
|
) {
|
|
1536
1552
|
// Cap the frame to the viewport and keep scrollback dirty: transient
|
|
1537
1553
|
// rows never enter history, and the checkpoint reconciles later.
|
|
1554
|
+
// Multiplexers (tmux/screen/zellij) are excluded: their checkpoint
|
|
1555
|
+
// reconcile is a no-op (pane history cannot be erased), so any rows
|
|
1556
|
+
// dropped here are dropped forever. Pane history is append-only
|
|
1557
|
+
// anyway, so a normal diff/append `\r\n` commit is exactly what the
|
|
1558
|
+
// multiplexer needs — and the `liveRegionPinned` planner above
|
|
1559
|
+
// keeps the actively-mutating live tail out of pane history while
|
|
1560
|
+
// committing only the sealed prefix (issue #1974).
|
|
1538
1561
|
this.#markNativeScrollbackDirty();
|
|
1539
1562
|
this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
|
|
1540
1563
|
this.#scrollbackHighWater = 0;
|
|
@@ -1602,6 +1625,7 @@ export class TUI extends Container {
|
|
|
1602
1625
|
clearViewport: true,
|
|
1603
1626
|
clearScrollback: !isMultiplexerSession(),
|
|
1604
1627
|
});
|
|
1628
|
+
this.#hasEverRendered = true;
|
|
1605
1629
|
return;
|
|
1606
1630
|
case "historyRebuild":
|
|
1607
1631
|
this.#clearNativeScrollbackDirty();
|
|
@@ -1637,6 +1661,16 @@ export class TUI extends Container {
|
|
|
1637
1661
|
}
|
|
1638
1662
|
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1639
1663
|
return;
|
|
1664
|
+
case "deferredTailRepaint":
|
|
1665
|
+
this.#emitDeferredTailRepaint(
|
|
1666
|
+
intent.line,
|
|
1667
|
+
width,
|
|
1668
|
+
height,
|
|
1669
|
+
intent.row,
|
|
1670
|
+
prevViewportTop,
|
|
1671
|
+
prevHardwareCursorRow,
|
|
1672
|
+
);
|
|
1673
|
+
return;
|
|
1640
1674
|
case "deferredMutation":
|
|
1641
1675
|
return;
|
|
1642
1676
|
case "deferredShrink":
|
|
@@ -1686,16 +1720,20 @@ export class TUI extends Container {
|
|
|
1686
1720
|
liveRegionStart: number | undefined,
|
|
1687
1721
|
commitSafeEnd: number | undefined,
|
|
1688
1722
|
): RenderIntent {
|
|
1723
|
+
// A forced scrollback wipe can be queued before start()'s initial paint runs
|
|
1724
|
+
// (cold `omp --resume` does this while replacing the welcome frame with the
|
|
1725
|
+
// restored transcript). Honor it before the normal initial-preserve path so
|
|
1726
|
+
// the first committed frame is the clean session replay, not a deferred wipe
|
|
1727
|
+
// that waits for the user's first keystroke.
|
|
1728
|
+
if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
|
|
1729
|
+
|
|
1689
1730
|
// Initial paint after start(): scrollback must keep its prior shell
|
|
1690
1731
|
// content, but the viewport must be cleared so stale rows do not bleed
|
|
1691
1732
|
// into the new UI.
|
|
1692
1733
|
if (!this.#hasEverRendered) return { kind: "initial" };
|
|
1693
1734
|
|
|
1694
|
-
// Caller opted into a scrollback wipe via requestRender(true, { clearScrollback: true }).
|
|
1695
|
-
if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
|
|
1696
|
-
|
|
1697
1735
|
const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
|
|
1698
|
-
const eagerEraseScrollbackRisk =
|
|
1736
|
+
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1699
1737
|
if (overlayVisibilityReduced && !isMultiplexerSession()) {
|
|
1700
1738
|
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1701
1739
|
}
|
|
@@ -1718,6 +1756,11 @@ export class TUI extends Container {
|
|
|
1718
1756
|
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1719
1757
|
}
|
|
1720
1758
|
|
|
1759
|
+
// Same dirty-scrollback opt-in policy as the non-overlay branch below: an
|
|
1760
|
+
// ED3-risk macOS/POSIX terminal with an unobservable viewport ignores
|
|
1761
|
+
// focused-input unknown opt-ins, so overlay selector Up/Down moves do not
|
|
1762
|
+
// become ED3 clears plus full transcript replays. Non-ED3-risk POSIX still
|
|
1763
|
+
// honors direct-input/IME/autocomplete opt-ins.
|
|
1721
1764
|
if (hasVisibleOverlay) {
|
|
1722
1765
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1723
1766
|
// Multiplexer panes never get a destructive scrollback clear
|
|
@@ -1725,10 +1768,11 @@ export class TUI extends Container {
|
|
|
1725
1768
|
// "rebuild" would only append a full duplicate copy of the transcript
|
|
1726
1769
|
// to pane history on every dirty frame. Keep repainting the viewport
|
|
1727
1770
|
// and leave reconciliation to explicit checkpoints.
|
|
1771
|
+
const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
|
|
1728
1772
|
if (
|
|
1729
1773
|
this.#nativeScrollbackDirty &&
|
|
1730
1774
|
!isMultiplexerSession() &&
|
|
1731
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom,
|
|
1775
|
+
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowDirtyUnknownViewportMutation)
|
|
1732
1776
|
) {
|
|
1733
1777
|
return { kind: "overlayRebuild" };
|
|
1734
1778
|
}
|
|
@@ -1759,12 +1803,19 @@ export class TUI extends Container {
|
|
|
1759
1803
|
this.#streamingHighWater = 0;
|
|
1760
1804
|
}
|
|
1761
1805
|
|
|
1762
|
-
if (
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1806
|
+
if (this.#nativeScrollbackDirty && !isMultiplexerSession()) {
|
|
1807
|
+
// A dirty flag means older native history is stale; it is not required to
|
|
1808
|
+
// make the current focused-input frame correct. On ED3-risk macOS/POSIX
|
|
1809
|
+
// terminals with an unobservable viewport, ignore focused-input unknown
|
|
1810
|
+
// opt-ins so Up/Down selector moves do not become ED3 clears plus full
|
|
1811
|
+
// transcript replays. Non-ED3-risk POSIX terminals keep their safe
|
|
1812
|
+
// direct-input/IME/autocomplete opt-in.
|
|
1813
|
+
const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
|
|
1814
|
+
if (
|
|
1815
|
+
this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowDirtyUnknownViewportMutation)
|
|
1816
|
+
) {
|
|
1817
|
+
return { kind: "historyRebuild" };
|
|
1818
|
+
}
|
|
1768
1819
|
}
|
|
1769
1820
|
|
|
1770
1821
|
const diff = this.#diffLines(newLines);
|
|
@@ -1802,18 +1853,28 @@ export class TUI extends Container {
|
|
|
1802
1853
|
//
|
|
1803
1854
|
const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
1804
1855
|
// ED3-risk terminals with an unobservable viewport cannot safely clear
|
|
1805
|
-
// saved lines.
|
|
1806
|
-
// live
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1856
|
+
// saved lines. Direct user-input frames (autocomplete/IME) may still
|
|
1857
|
+
// repaint the live viewport: the user action pins the host to the tail, and
|
|
1858
|
+
// emitting zero bytes leaves stale autocomplete rows on screen until a later
|
|
1859
|
+
// checkpoint. When the changed rows are at or below the previous viewport
|
|
1860
|
+
// top, keep the old bottom anchor by padding the frame to its previous
|
|
1861
|
+
// length; that clears stale popup rows without re-exposing rows already
|
|
1862
|
+
// committed to native history. If an offscreen edit shifted rows above the
|
|
1863
|
+
// viewport, padding would repaint the wrong seam, so use a viewport repaint
|
|
1864
|
+
// for liveness and keep history dirty. Active eager streaming also uses a
|
|
1865
|
+
// viewport repaint so the live tail keeps moving. With neither direct input
|
|
1866
|
+
// nor active eager streaming, the reader may be scrolled, so defer
|
|
1813
1867
|
// completely rather than repainting over their history.
|
|
1814
1868
|
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
|
|
1815
1869
|
this.#markNativeScrollbackDirty();
|
|
1816
|
-
|
|
1870
|
+
if (allowUnknownViewportMutation) {
|
|
1871
|
+
return diff.firstChanged < prevViewportTop
|
|
1872
|
+
? { kind: "viewportRepaint" }
|
|
1873
|
+
: { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1874
|
+
}
|
|
1875
|
+
return this.#eagerNativeScrollbackRebuild
|
|
1876
|
+
? { kind: "viewportRepaint" }
|
|
1877
|
+
: this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
|
|
1817
1878
|
}
|
|
1818
1879
|
|
|
1819
1880
|
// Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
|
|
@@ -1825,7 +1886,7 @@ export class TUI extends Container {
|
|
|
1825
1886
|
}
|
|
1826
1887
|
this.#markNativeScrollbackDirty();
|
|
1827
1888
|
if (diff.firstChanged < prevViewportTop) {
|
|
1828
|
-
return
|
|
1889
|
+
return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
|
|
1829
1890
|
}
|
|
1830
1891
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1831
1892
|
}
|
|
@@ -1924,21 +1985,30 @@ export class TUI extends Container {
|
|
|
1924
1985
|
const contentGrew = newLines.length > this.#previousLines.length;
|
|
1925
1986
|
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1926
1987
|
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
|
|
1927
|
-
if (pureAppend && contentGrew && this.#previousLines.length
|
|
1988
|
+
if (pureAppend && contentGrew && this.#previousLines.length >= height && !isMultiplexerSession()) {
|
|
1928
1989
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1990
|
+
if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
|
|
1991
|
+
this.#markNativeScrollbackDirty();
|
|
1992
|
+
return { kind: "deferredMutation" };
|
|
1993
|
+
}
|
|
1994
|
+
if (nativeViewportAtBottom === undefined && allowUnknownViewportMutation) {
|
|
1995
|
+
// Direct input can grow transient live UI (autocomplete/IME/editor
|
|
1996
|
+
// wraps) while the previous frame already touched the viewport bottom.
|
|
1997
|
+
// A diff append would `\r\n`-scroll those transient rows into native
|
|
1998
|
+
// history, and a later popup shrink would duplicate the stable prefix at
|
|
1999
|
+
// the scrollback seam. Repaint the live viewport in place instead; the
|
|
2000
|
+
// dirty checkpoint owns native-history reconciliation.
|
|
2001
|
+
this.#markNativeScrollbackDirty();
|
|
2002
|
+
return { kind: "viewportRepaint" };
|
|
2003
|
+
}
|
|
1929
2004
|
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1930
2005
|
this.#markNativeScrollbackDirty();
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
// Fall through to a non-destructive viewport repaint instead so the
|
|
1938
|
-
// live UI keeps updating without yanking a possibly-scrolled reader.
|
|
1939
|
-
if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
|
|
1940
|
-
return { kind: "deferredMutation" };
|
|
1941
|
-
}
|
|
2006
|
+
// Unknown viewport (e.g. native Windows Terminal where the probe cannot
|
|
2007
|
+
// see WT host scrollback) is a different case: a no-op there freezes the
|
|
2008
|
+
// editor on the keystroke that grows `lines.length` past the viewport
|
|
2009
|
+
// (the wrap keystroke). Fall through to a non-destructive viewport
|
|
2010
|
+
// repaint instead so the live UI keeps updating without yanking a
|
|
2011
|
+
// possibly-scrolled reader.
|
|
1942
2012
|
return { kind: "viewportRepaint" };
|
|
1943
2013
|
}
|
|
1944
2014
|
}
|
|
@@ -2022,7 +2092,7 @@ export class TUI extends Container {
|
|
|
2022
2092
|
!cleanTailAppend &&
|
|
2023
2093
|
!this.#eagerNativeScrollbackRebuild
|
|
2024
2094
|
) {
|
|
2025
|
-
return
|
|
2095
|
+
return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
|
|
2026
2096
|
}
|
|
2027
2097
|
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
|
|
2028
2098
|
}
|
|
@@ -2108,6 +2178,11 @@ export class TUI extends Container {
|
|
|
2108
2178
|
this.#nativeScrollbackDirty = false;
|
|
2109
2179
|
}
|
|
2110
2180
|
|
|
2181
|
+
#hasEagerEraseScrollbackRisk(): boolean {
|
|
2182
|
+
if (process.platform === "win32") return false;
|
|
2183
|
+
return this.terminal.hasEagerEraseScrollbackRisk?.() ?? TERMINAL.eagerEraseScrollbackRisk;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2111
2186
|
#readNativeViewportAtBottom(): boolean | undefined {
|
|
2112
2187
|
// A stale positive is destructive: live history rebuilds clear native
|
|
2113
2188
|
// scrollback. Require two consecutive at-bottom probes before trusting it.
|
|
@@ -2174,11 +2249,18 @@ export class TUI extends Container {
|
|
|
2174
2249
|
liveRegionStart >= newLines.length ||
|
|
2175
2250
|
!this.#eagerNativeScrollbackRebuild ||
|
|
2176
2251
|
!eagerEraseScrollbackRisk ||
|
|
2177
|
-
allowUnknownViewportMutation
|
|
2178
|
-
isMultiplexerSession()
|
|
2252
|
+
allowUnknownViewportMutation
|
|
2179
2253
|
) {
|
|
2180
2254
|
return undefined;
|
|
2181
2255
|
}
|
|
2256
|
+
// Multiplexers (tmux/screen/zellij) cannot erase pane history with `\x1b[3J`
|
|
2257
|
+
// and cannot answer a viewport-position probe, so the destructive checkpoint
|
|
2258
|
+
// rebuild path is forever unavailable. The pinned emitter is built from the
|
|
2259
|
+
// opposite primitives — relative cursor moves, per-line `\x1b[2K`, and
|
|
2260
|
+
// `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
|
|
2261
|
+
// what tmux pane history accepts. Without this commit-as-you-go path, the
|
|
2262
|
+
// streaming cap below clipped every frame to the visible tail and the
|
|
2263
|
+
// scrolled-off head was committed nowhere (issue #1974).
|
|
2182
2264
|
if (newLines.length <= height && this.#scrollbackHighWater === 0) return undefined;
|
|
2183
2265
|
if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
|
|
2184
2266
|
|
|
@@ -2209,6 +2291,19 @@ export class TUI extends Container {
|
|
|
2209
2291
|
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2210
2292
|
}
|
|
2211
2293
|
|
|
2294
|
+
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
|
|
2295
|
+
const row = prevViewportTop + height - 1;
|
|
2296
|
+
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
|
|
2297
|
+
return { kind: "deferredMutation" };
|
|
2298
|
+
}
|
|
2299
|
+
const line = newLines[row] ?? "";
|
|
2300
|
+
const previousLine = this.#deferredTailLine ?? this.#previousLines[row] ?? "";
|
|
2301
|
+
if (line === previousLine) {
|
|
2302
|
+
return { kind: "deferredMutation" };
|
|
2303
|
+
}
|
|
2304
|
+
return { kind: "deferredTailRepaint", row, line };
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2212
2307
|
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
|
2213
2308
|
if (lines.length >= paddedLength) return lines;
|
|
2214
2309
|
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
|
|
@@ -2240,6 +2335,7 @@ export class TUI extends Container {
|
|
|
2240
2335
|
*/
|
|
2241
2336
|
|
|
2242
2337
|
#commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
|
|
2338
|
+
this.#deferredTailLine = undefined;
|
|
2243
2339
|
this.#previousLines = lines;
|
|
2244
2340
|
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
|
|
2245
2341
|
this.#forceViewportRepaintOnNextRender = false;
|
|
@@ -2536,6 +2632,41 @@ export class TUI extends Container {
|
|
|
2536
2632
|
}
|
|
2537
2633
|
}
|
|
2538
2634
|
|
|
2635
|
+
/**
|
|
2636
|
+
* Paint only the active-grid bottom row while a scrollback mutation remains
|
|
2637
|
+
* deferred. If the native viewport is unknown and the user is scrolled up by a
|
|
2638
|
+
* single line, every active-grid row except the bottom can still be visible in
|
|
2639
|
+
* their scrollback window; touching only this row keeps that reader's viewport
|
|
2640
|
+
* unchanged while allowing bottom-anchored live chrome (spinner/status tail) to
|
|
2641
|
+
* advance for users at the tail.
|
|
2642
|
+
*/
|
|
2643
|
+
#emitDeferredTailRepaint(
|
|
2644
|
+
line: string,
|
|
2645
|
+
width: number,
|
|
2646
|
+
height: number,
|
|
2647
|
+
row: number,
|
|
2648
|
+
prevViewportTop: number,
|
|
2649
|
+
prevHardwareCursorRow: number,
|
|
2650
|
+
): void {
|
|
2651
|
+
const viewportBottom = prevViewportTop + height - 1;
|
|
2652
|
+
if (row !== viewportBottom) return;
|
|
2653
|
+
|
|
2654
|
+
let buffer = this.#paintBeginSequence;
|
|
2655
|
+
const clampedCursor = Math.min(prevHardwareCursorRow, viewportBottom);
|
|
2656
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
|
2657
|
+
const moveDown = height - 1 - currentScreenRow;
|
|
2658
|
+
if (moveDown > 0) buffer += `\x1b[${moveDown}B`;
|
|
2659
|
+
buffer += `\r\x1b[2K${this.#fitLineToWidth(line, width)}\x1b[?25l`;
|
|
2660
|
+
buffer += this.#paintEndSequence;
|
|
2661
|
+
this.terminal.write(buffer);
|
|
2662
|
+
|
|
2663
|
+
this.#deferredTailLine = line;
|
|
2664
|
+
this.#previousWidth = width;
|
|
2665
|
+
this.#previousHeight = height;
|
|
2666
|
+
this.#viewportTopRow = prevViewportTop;
|
|
2667
|
+
this.#hardwareCursorRow = row;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2539
2670
|
/**
|
|
2540
2671
|
* Trailing-shrink: prior content shared a prefix with the new content; the
|
|
2541
2672
|
* extra rows below the new tail need to be cleared without scrolling. Falls
|
|
@@ -2740,7 +2871,9 @@ export class TUI extends Container {
|
|
|
2740
2871
|
? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
|
|
2741
2872
|
: intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
|
|
2742
2873
|
? `${intent.kind}(appendFrom=${intent.appendFrom})`
|
|
2743
|
-
: intent.kind
|
|
2874
|
+
: intent.kind === "deferredTailRepaint"
|
|
2875
|
+
? `${intent.kind}(row=${intent.row})`
|
|
2876
|
+
: intent.kind;
|
|
2744
2877
|
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
|
|
2745
2878
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
2746
2879
|
}
|