@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 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 {};
@@ -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";
@@ -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.
@@ -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 and
235
- * direct user-input rebuilds are unaffected.
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.5",
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.5",
41
- "@oh-my-pi/pi-utils": "15.9.5",
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
  },
@@ -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
- lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
117
+ rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
114
118
  }
115
119
 
116
- // Add scroll/search status when needed
117
- if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) {
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 searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : "";
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
- // Calculate visible range with scrolling
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(this.#maxVisible / 2), this.#items.length - this.#maxVisible),
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
- // Render visible items
112
- for (let i = startIndex; i < endIndex; i++) {
113
- const item = this.#items[i];
114
- if (!item) continue;
115
-
116
- const isSelected = i === this.#selectedIndex;
117
- const prefix = isSelected ? this.#theme.cursor : " ";
118
- const prefixWidth = visibleWidth(prefix);
119
-
120
- // Pad label to align values
121
- const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
122
- const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
123
-
124
- // Calculate space for value
125
- const separator = " ";
126
- const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
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 and
573
- * direct user-input rebuilds are unaffected.
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 (process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk) {
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 = process.platform !== "win32" && TERMINAL.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 = process.platform !== "win32" && TERMINAL.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, allowUnknownViewportMutation)
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
- this.#nativeScrollbackDirty &&
1764
- !isMultiplexerSession() &&
1765
- this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)
1766
- ) {
1767
- return { kind: "historyRebuild" };
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. During an active eager streaming turn the user follows the
1806
- // live tail, so paint the shrink's bottom-anchored viewport in place
1807
- // whether it still overflows OR now fits otherwise the UI freezes on
1808
- // stale rows until the next input even though the frame has a fresh bottom
1809
- // viewport to show (issues #1682, foreground-stream fidelity on collapse).
1810
- // Native history stays dirty and reconciles at the next checkpoint. With no
1811
- // active eager turn the reader may be scrolled; even a padded shrink repaint
1812
- // can move ED3-risk unknown host scrollback (WSL/Ghostty-style), so defer
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
- return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
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 { kind: "deferredMutation" };
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 > height && !isMultiplexerSession()) {
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
- // Confirmed scrolled (probe returned `false`): the reader is parked in
1932
- // scrollback and writing the live frame is wasted bytes defer until
1933
- // the next checkpoint reconciles. Unknown viewport (e.g. native Windows
1934
- // Terminal where the probe cannot see WT host scrollback) is a
1935
- // different case: a no-op there freezes the editor on the keystroke
1936
- // that grows `lines.length` past the viewport (the wrap keystroke).
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 { kind: "deferredMutation" };
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
  }