@oh-my-pi/pi-tui 15.7.2 → 15.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.7.3] - 2026-05-31
6
+
7
+ ### Added
8
+
9
+ - Added `overflowSearch` to `SelectListLayoutOptions` to let consumers enable or disable type-to-filter search and search-status rendering per SelectList instance
10
+ - Added fuzzy type-to-filter search to overflowing `SelectList` pickers, with search status and result counts.
11
+ - Added `TUI.setEagerNativeScrollbackRebuild(enabled)` — while enabled, live render frames rebuild native scrollback on offscreen/structural changes even when the viewport position is unobservable (POSIX), instead of deferring to a non-destructive repaint. Trades the anti-yank guarantee for clean, duplicate-free history; intended for windows where output above the fold is actively re-laying out (e.g. a tool whose result is still streaming). A terminal that reports a known-scrolled viewport still defers.
12
+
13
+ ### Changed
14
+
15
+ - Disabled interactive search filtering for editor autocomplete and slash-command `SelectList`s by passing `overflowSearch: false` in their layout options
16
+
17
+ ### Fixed
18
+
19
+ - Preserved hidden tmux overlays in the live viewport by removing overlay content from view when an overlay was hidden while keeping pane history intact
20
+ - Preserved native scrollback when forced TUI renders coalesce with content growth, and deferred pure tail appends while readers are scrolled into history.
21
+ - Preserved existing terminal scrollback during forced and structural TUI renders so preexisting shell lines remained visible after component mutations
22
+ - Rebuilt native scrollback for safe bottom-anchored offscreen edits and high-water preview collapses instead of repainting only the viewport, preventing stale or duplicated rows above the live viewport.
23
+ - Stripped internal cursor marker sentinels from all rendered lines so offscreen focus markers no longer leak into terminal output
24
+ - Truncated all painted lines to terminal width during viewport repaints and append-tail updates so long content no longer overflows or wraps unexpectedly
25
+ - Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown
26
+ - Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer.
27
+ - Fixed scrolled-up readers being yanked back to the tail whenever streaming content arrived on POSIX terminals (macOS/Linux). Native viewport position is unobservable there (`isNativeViewportAtBottom()` returns `undefined`), and the planner optimistically treated "unknown" as "at bottom", so every offscreen streaming edit ran a destructive `historyRebuild` that cleared scrollback and snapped the view to the bottom. Live render frames now treat an unknown viewport as unsafe for a destructive rebuild — they defer to a non-destructive viewport repaint and reconcile native scrollback at the next explicit checkpoint (prompt submit). Resize and checkpoint replays keep the prior behavior.
28
+ - Fixed native scrollback not rewrapping when the terminal widens on POSIX. A width increase reflows the transcript to fewer lines, which the shrink-across-boundary branch intercepted and (after the unknown-viewport deferral) repainted only the viewport — leaving committed history wrapped at the old width and duplicated above the live viewport. Width changes now rebuild native scrollback at the new geometry even when the viewport position is unknown (a yank is acceptable on an explicit resize); a terminal that can report a scrolled viewport still defers.
29
+
5
30
  ## [15.7.0] - 2026-05-31
6
31
 
7
32
  ### Fixed
@@ -26,6 +26,8 @@ export interface SelectListLayoutOptions {
26
26
  minPrimaryColumnWidth?: number;
27
27
  maxPrimaryColumnWidth?: number;
28
28
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
29
+ /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
30
+ overflowSearch?: boolean;
29
31
  }
30
32
  export declare class SelectList implements Component {
31
33
  #private;
@@ -164,6 +164,18 @@ export declare class TUI extends Container {
164
164
  * When false, empty rows remain (reduces redraws on slower terminals).
165
165
  */
166
166
  setClearOnShrink(enabled: boolean): void;
167
+ /**
168
+ * When enabled, live render frames rebuild native scrollback on offscreen and
169
+ * structural changes even when the viewport position is unobservable (POSIX,
170
+ * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
171
+ * non-destructive repaint. This trades the anti-yank guarantee for a clean,
172
+ * duplicate-free history and is meant for windows where output above the fold
173
+ * is actively re-rendering — e.g. a tool whose result is still streaming and
174
+ * re-laying-out rows that have already scrolled into history. A snap to the tail
175
+ * is acceptable there. A terminal that can report a *known*-scrolled viewport
176
+ * (Windows) still defers; only the unknown case is forced to rebuild.
177
+ */
178
+ setEagerNativeScrollbackRebuild(enabled: boolean): void;
167
179
  setFocus(component: Component | null): void;
168
180
  /**
169
181
  * Show an overlay component with configurable positioning and sizing.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.7.2",
4
+ "version": "15.7.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -32,13 +32,13 @@
32
32
  "check": "biome check . && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
34
34
  "lint": "biome lint .",
35
- "test": "bun test test/*.test.ts",
35
+ "test": "bun test --parallel test/*.test.ts",
36
36
  "fix": "biome check --write --unsafe .",
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.7.2",
41
- "@oh-my-pi/pi-utils": "15.7.2",
40
+ "@oh-my-pi/pi-natives": "15.7.3",
41
+ "@oh-my-pi/pi-utils": "15.7.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -19,9 +19,14 @@ import {
19
19
  } from "../utils";
20
20
  import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
21
21
 
22
+ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
23
+ overflowSearch: false,
24
+ };
25
+
22
26
  const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
23
27
  minPrimaryColumnWidth: 12,
24
28
  maxPrimaryColumnWidth: 32,
29
+ overflowSearch: false,
25
30
  };
26
31
 
27
32
  function sanitizeLoadedText(text: string): string {
@@ -2524,11 +2529,8 @@ export class Editor implements Component, Focusable {
2524
2529
  prefix: string,
2525
2530
  items: Array<{ value: string; label: string; description?: string }>,
2526
2531
  ): SelectList {
2527
- // Layout options prepared for future SelectList enhancements (e.g., for slash commands)
2528
- const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2529
- // TODO: Pass layout to SelectList when constructor is updated to support it
2530
- void layout; // Use layout variable to avoid lint warnings
2531
- return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList);
2532
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT;
2533
+ return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
2532
2534
  }
2533
2535
 
2534
2536
  #handleTabCompletion(): void {
@@ -1,4 +1,6 @@
1
+ import { fuzzyFilter } from "../fuzzy";
1
2
  import { getKeybindings } from "../keybindings";
3
+ import { extractPrintableText } from "../keys";
2
4
  import type { SymbolTheme } from "../symbols";
3
5
  import type { Component } from "../tui";
4
6
  import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
@@ -45,10 +47,13 @@ export interface SelectListLayoutOptions {
45
47
  minPrimaryColumnWidth?: number;
46
48
  maxPrimaryColumnWidth?: number;
47
49
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
50
+ /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
51
+ overflowSearch?: boolean;
48
52
  }
49
53
 
50
54
  export class SelectList implements Component {
51
55
  #filteredItems: ReadonlyArray<SelectItem>;
56
+ #filterQuery = "";
52
57
  #selectedIndex: number = 0;
53
58
 
54
59
  onSelect?: (item: SelectItem) => void;
@@ -65,9 +70,7 @@ export class SelectList implements Component {
65
70
  }
66
71
 
67
72
  setFilter(filter: string): void {
68
- this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
69
- // Reset selection when filter changes
70
- this.#selectedIndex = 0;
73
+ this.#setFilter(filter, true);
71
74
  }
72
75
 
73
76
  setSelectedIndex(index: number): void {
@@ -80,10 +83,14 @@ export class SelectList implements Component {
80
83
 
81
84
  render(width: number): string[] {
82
85
  const lines: string[] = [];
86
+ const showSearchStatus = this.#shouldRenderSearchStatus();
83
87
 
84
88
  // If no items match filter, show message
85
89
  if (this.#filteredItems.length === 0) {
86
- lines.push(this.theme.noMatch(" No matching commands"));
90
+ if (showSearchStatus) {
91
+ lines.push(this.#renderStatusLine(width));
92
+ }
93
+ lines.push(this.theme.noMatch(" No matching items"));
87
94
  return lines;
88
95
  }
89
96
 
@@ -106,19 +113,29 @@ export class SelectList implements Component {
106
113
  lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
107
114
  }
108
115
 
109
- // Add scroll indicators if needed
110
- if (startIndex > 0 || endIndex < this.#filteredItems.length) {
111
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`;
112
- // Truncate if too long for terminal
113
- lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
116
+ // Add scroll/search status when needed
117
+ if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) {
118
+ lines.push(this.#renderStatusLine(width));
114
119
  }
115
120
 
116
121
  return lines;
117
122
  }
118
123
 
119
124
  handleInput(keyData: string): void {
120
- if (this.#filteredItems.length === 0) return;
121
125
  const kb = getKeybindings();
126
+ // Escape or Ctrl+C
127
+ if (kb.matches(keyData, "tui.select.cancel")) {
128
+ if (this.onCancel) {
129
+ this.onCancel();
130
+ }
131
+ return;
132
+ }
133
+
134
+ if (this.#handleSearchInput(keyData)) {
135
+ return;
136
+ }
137
+
138
+ if (this.#filteredItems.length === 0) return;
122
139
  // Up arrow - wrap to bottom when at top
123
140
  if (kb.matches(keyData, "tui.select.up")) {
124
141
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
@@ -146,12 +163,6 @@ export class SelectList implements Component {
146
163
  this.onSelect(selectedItem);
147
164
  }
148
165
  }
149
- // Escape or Ctrl+C
150
- else if (kb.matches(keyData, "tui.select.cancel")) {
151
- if (this.onCancel) {
152
- this.onCancel();
153
- }
154
- }
155
166
  }
156
167
 
157
168
  #renderItem(
@@ -235,6 +246,71 @@ export class SelectList implements Component {
235
246
  return sanitizeSingleLine(item.label || item.value);
236
247
  }
237
248
 
249
+ #renderStatusLine(width: number): string {
250
+ const selectedCount = this.#filteredItems.length === 0 ? 0 : this.#selectedIndex + 1;
251
+ const filteredCount = this.#filteredItems.length;
252
+ const count =
253
+ this.#filterQuery.trim() && filteredCount !== this.items.length
254
+ ? `${selectedCount}/${filteredCount} of ${this.items.length}`
255
+ : `${selectedCount}/${filteredCount}`;
256
+ const query = sanitizeSingleLine(this.#filterQuery);
257
+ const searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : "";
258
+ const statusText = ` (${count})${searchSuffix}`;
259
+ return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
260
+ }
261
+
262
+ #shouldRenderSearchStatus(): boolean {
263
+ return (
264
+ this.layout.overflowSearch !== false && (this.items.length > this.maxVisible || this.#filterQuery.length > 0)
265
+ );
266
+ }
267
+
268
+ #canEditSearch(): boolean {
269
+ return this.layout.overflowSearch !== false && this.items.length > this.maxVisible;
270
+ }
271
+
272
+ #handleSearchInput(keyData: string): boolean {
273
+ if (!this.#canEditSearch()) return false;
274
+
275
+ const kb = getKeybindings();
276
+ if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
277
+ if (this.#filterQuery.length === 0) return false;
278
+ const chars = [...this.#filterQuery];
279
+ chars.pop();
280
+ this.#setFilter(chars.join(""), true);
281
+ return true;
282
+ }
283
+
284
+ const printableText = extractPrintableText(keyData);
285
+ if (printableText === undefined) return false;
286
+ if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
287
+
288
+ this.#setFilter(this.#filterQuery + printableText, true);
289
+ return true;
290
+ }
291
+
292
+ #setFilter(filter: string, notify: boolean): void {
293
+ this.#filterQuery = filter;
294
+ this.#filteredItems = filter.trim()
295
+ ? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item))
296
+ : this.items;
297
+ this.#selectedIndex = 0;
298
+ if (notify) {
299
+ this.#notifySelectionChange();
300
+ }
301
+ }
302
+
303
+ #getFilterText(item: SelectItem): string {
304
+ let text = `${item.label} ${item.value}`;
305
+ if (item.description) {
306
+ text += ` ${item.description}`;
307
+ }
308
+ if (item.hint) {
309
+ text += ` ${item.hint}`;
310
+ }
311
+ return sanitizeSingleLine(text);
312
+ }
313
+
238
314
  #notifySelectionChange(): void {
239
315
  const selectedItem = this.#filteredItems[this.#selectedIndex];
240
316
  if (selectedItem && this.onSelectionChange) {
package/src/tui.ts CHANGED
@@ -282,6 +282,7 @@ type RenderIntent =
282
282
  | { kind: "initial" }
283
283
  | { kind: "sessionReplace" }
284
284
  | { kind: "historyRebuild" }
285
+ | { kind: "overlayRebuild" }
285
286
  | { kind: "viewportRepaint"; appendFrom?: number }
286
287
  | { kind: "deferredShrink"; paddedLength: number }
287
288
  | { kind: "deferredMutation" }
@@ -328,7 +329,9 @@ export class TUI extends Container {
328
329
  #nativeScrollbackDirty = false;
329
330
  #fullRedrawCount = 0;
330
331
  #clearScrollbackOnNextRender = false;
332
+ #forceViewportRepaintOnNextRender = false;
331
333
  #allowUnknownViewportMutationOnNextRender = false;
334
+ #eagerNativeScrollbackRebuild = false;
332
335
  #hasEverRendered = false;
333
336
  #stopped = false;
334
337
 
@@ -376,6 +379,21 @@ export class TUI extends Container {
376
379
  this.#clearOnShrink = enabled;
377
380
  }
378
381
 
382
+ /**
383
+ * When enabled, live render frames rebuild native scrollback on offscreen and
384
+ * structural changes even when the viewport position is unobservable (POSIX,
385
+ * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
386
+ * non-destructive repaint. This trades the anti-yank guarantee for a clean,
387
+ * duplicate-free history and is meant for windows where output above the fold
388
+ * is actively re-rendering — e.g. a tool whose result is still streaming and
389
+ * re-laying-out rows that have already scrolled into history. A snap to the tail
390
+ * is acceptable there. A terminal that can report a *known*-scrolled viewport
391
+ * (Windows) still defers; only the unknown case is forced to rebuild.
392
+ */
393
+ setEagerNativeScrollbackRebuild(enabled: boolean): void {
394
+ this.#eagerNativeScrollbackRebuild = enabled;
395
+ }
396
+
379
397
  setFocus(component: Component | null): void {
380
398
  // Clear focused flag on old component
381
399
  if (isFocusable(this.#focusedComponent)) {
@@ -675,7 +693,7 @@ export class TUI extends Container {
675
693
  ) {
676
694
  return false;
677
695
  }
678
- this.#prepareForcedRender(true);
696
+ this.#prepareForcedRender(true, options?.allowUnknownViewport === true);
679
697
  this.#renderRequested = false;
680
698
  this.#lastRenderAt = performance.now();
681
699
  this.#doRender();
@@ -683,9 +701,10 @@ export class TUI extends Container {
683
701
  }
684
702
 
685
703
  requestRender(force = false, options?: RenderRequestOptions): void {
686
- this.#allowUnknownViewportMutationOnNextRender ||= options?.allowUnknownViewportMutation === true;
704
+ const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
705
+ this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
687
706
  if (force) {
688
- this.#prepareForcedRender(options?.clearScrollback === true);
707
+ this.#prepareForcedRender(options?.clearScrollback === true, allowUnknownViewportMutation);
689
708
  this.#renderRequested = true;
690
709
  process.nextTick(() => {
691
710
  if (this.#stopped || !this.#renderRequested) {
@@ -702,15 +721,15 @@ export class TUI extends Container {
702
721
  process.nextTick(() => this.#scheduleRender());
703
722
  }
704
723
 
705
- #prepareForcedRender(clearScrollback: boolean): void {
706
- this.#clearScrollbackOnNextRender ||= clearScrollback;
707
- this.#previousLines = [];
708
- this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
709
- this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
710
- this.#cursorRow = 0;
711
- this.#hardwareCursorRow = 0;
712
- this.#viewportTopRow = 0;
713
- this.#maxLinesRendered = 0;
724
+ #prepareForcedRender(clearScrollback: boolean, allowUnknownViewportMutation: boolean): void {
725
+ const geometryChanged =
726
+ (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
727
+ (this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
728
+ const replayGeometry =
729
+ geometryChanged &&
730
+ this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
731
+ this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
732
+ this.#forceViewportRepaintOnNextRender = true;
714
733
  if (this.#renderTimer) {
715
734
  clearTimeout(this.#renderTimer);
716
735
  this.#renderTimer = undefined;
@@ -1090,23 +1109,27 @@ export class TUI extends Container {
1090
1109
  * @returns Cursor position { row, col } or null if no marker found
1091
1110
  */
1092
1111
  #extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
1093
- // Only scan the bottom `height` lines (visible viewport)
1112
+ // Cursor markers are internal sentinels and must never reach the terminal,
1113
+ // even when the focused component is above the visible viewport. Only a
1114
+ // visible marker becomes a hardware cursor target.
1094
1115
  const viewportTop = Math.max(0, lines.length - height);
1095
- for (let row = lines.length - 1; row >= viewportTop; row--) {
1116
+ let cursor: { row: number; col: number } | null = null;
1117
+ for (let row = lines.length - 1; row >= 0; row--) {
1096
1118
  const line = lines[row];
1097
- const markerIndex = line.indexOf(CURSOR_MARKER);
1098
- if (markerIndex !== -1) {
1099
- // Calculate visual column (width of text before marker)
1119
+ let markerIndex = line.indexOf(CURSOR_MARKER);
1120
+ if (markerIndex === -1) continue;
1121
+ if (cursor === null && row >= viewportTop) {
1100
1122
  const beforeMarker = line.slice(0, markerIndex);
1101
- const col = visibleWidth(beforeMarker);
1102
-
1103
- // Strip marker from the line
1104
- lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1105
-
1106
- return { row, col };
1123
+ cursor = { row, col: visibleWidth(beforeMarker) };
1124
+ }
1125
+ let stripped = line;
1126
+ while (markerIndex !== -1) {
1127
+ stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1128
+ markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1107
1129
  }
1130
+ lines[row] = stripped;
1108
1131
  }
1109
- return null;
1132
+ return cursor;
1110
1133
  }
1111
1134
 
1112
1135
  /**
@@ -1140,19 +1163,25 @@ export class TUI extends Container {
1140
1163
  const height = this.terminal.rows;
1141
1164
 
1142
1165
  // 1. Compose the frame.
1143
- let lines = this.render(width);
1166
+ let baseLines = this.render(width);
1167
+ let lines = baseLines;
1144
1168
  if (this.overlayStack.length > 0) {
1145
- lines = this.#compositeOverlays(lines, width, height);
1169
+ lines = this.#compositeOverlays(baseLines, width, height);
1146
1170
  }
1147
1171
  const cursorPos = this.#extractCursorPosition(lines, height);
1148
- lines = this.#applyLineResets(lines);
1172
+ lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
1173
+ if (lines !== baseLines) {
1174
+ this.#extractCursorPosition(baseLines, height);
1175
+ baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
1176
+ }
1149
1177
 
1150
1178
  // 2. Capture transition + pre-render state before any emitter runs.
1151
1179
  const prevViewportTop = this.#viewportTopRow;
1152
1180
  const prevHardwareCursorRow = this.#hardwareCursorRow;
1153
1181
  const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
1154
1182
  const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height;
1155
- const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
1183
+ const allowUnknownViewportMutation =
1184
+ this.#allowUnknownViewportMutationOnNextRender || this.#eagerNativeScrollbackRebuild;
1156
1185
  this.#allowUnknownViewportMutationOnNextRender = false;
1157
1186
 
1158
1187
  // 3. Classify intent.
@@ -1192,9 +1221,17 @@ export class TUI extends Container {
1192
1221
  clearScrollback: !isMultiplexerSession(),
1193
1222
  });
1194
1223
  return;
1224
+ case "overlayRebuild":
1225
+ this.#clearNativeScrollbackDirty();
1226
+ this.#emitFullPaint(baseLines, width, height, null, {
1227
+ clearViewport: true,
1228
+ clearScrollback: !isMultiplexerSession(),
1229
+ });
1230
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
1231
+ return;
1195
1232
  case "viewportRepaint":
1196
1233
  if (intent.appendFrom !== undefined) {
1197
- this.#emitAppendTail(lines, intent.appendFrom, height, prevViewportTop, prevHardwareCursorRow);
1234
+ this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
1198
1235
  }
1199
1236
  this.#emitViewportRepaint(lines, width, height, cursorPos);
1200
1237
  return;
@@ -1251,23 +1288,31 @@ export class TUI extends Container {
1251
1288
  // Caller opted into a scrollback wipe via requestRender(true, { clearScrollback: true }).
1252
1289
  if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
1253
1290
 
1254
- // Forced reset (requestRender(true)) without scrollback wipe: previous
1255
- // lines were dropped, so no diff is possible. Repaint visible rows only
1256
- // emitting the transcript here would duplicate it into scrollback.
1257
- if (this.#previousLines.length === 0) return { kind: "viewportRepaint" };
1291
+ const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
1292
+ if (this.hasOverlay()) {
1293
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1294
+ if (
1295
+ this.#nativeScrollbackDirty &&
1296
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1297
+ ) {
1298
+ return { kind: "overlayRebuild" };
1299
+ }
1300
+ this.#markNativeScrollbackDirty();
1301
+ return { kind: "viewportRepaint" };
1302
+ }
1303
+
1258
1304
  if (this.#nativeScrollbackDirty && this.#nativeViewportIsAtBottom(this.#readNativeViewportAtBottom())) {
1259
1305
  return { kind: "historyRebuild" };
1260
1306
  }
1261
1307
 
1262
1308
  const diff = this.#diffLines(newLines);
1263
1309
  // Shrink across the viewport boundary: the new transcript would re-expose
1264
- // rows already committed to native scrollback. A real resize already
1265
- // reflowed history, so rebuild it now; a pure content shrink (e.g. a
1266
- // streaming tail cell collapsing) defers the clear+replay. When the terminal
1267
- // can report that the user is scrolled into history, the live repaint keeps
1268
- // the previous row count with blank tail padding; otherwise cursor-home
1269
- // repainting rewrites old buffer rows with newly bottom-anchored content,
1270
- // which looks like a jump upward.
1310
+ // rows already committed to native scrollback. Rebuild immediately when the
1311
+ // viewport is known/allowed to be at the tail; otherwise defer the rewrite
1312
+ // and repaint against the previous row count so users scrolled into history
1313
+ // are not yanked. A viewport-only repaint for a bottom-anchored shrink leaves
1314
+ // stale high-water rows in native scrollback and duplicates the new tail above
1315
+ // the viewport.
1271
1316
  const naturalViewportTop = Math.max(0, newLines.length - height);
1272
1317
  if (
1273
1318
  diff.firstChanged !== -1 &&
@@ -1275,17 +1320,22 @@ export class TUI extends Container {
1275
1320
  naturalViewportTop < this.#scrollbackHighWater &&
1276
1321
  !isMultiplexerSession()
1277
1322
  ) {
1278
- if (widthChanged || heightChanged) {
1279
- if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
1280
- this.#markNativeScrollbackDirty();
1281
- return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1282
- }
1323
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1324
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1325
+ this.#markNativeScrollbackDirty();
1326
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1327
+ }
1328
+ // A width change rewraps the whole transcript, so committed scrollback is
1329
+ // mis-wrapped at the old width. Yank is acceptable on an explicit resize, so
1330
+ // rebuild even when the viewport position is unknown (POSIX); the
1331
+ // known-scrolled case already deferred above.
1332
+ if (
1333
+ widthChanged ||
1334
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1335
+ ) {
1283
1336
  return { kind: "historyRebuild" };
1284
1337
  }
1285
1338
  this.#markNativeScrollbackDirty();
1286
- if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
1287
- return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1288
- }
1289
1339
  return { kind: "viewportRepaint" };
1290
1340
  }
1291
1341
 
@@ -1297,13 +1347,36 @@ export class TUI extends Container {
1297
1347
  diff.firstChanged < this.#previousLines.length &&
1298
1348
  !isMultiplexerSession()
1299
1349
  ) {
1350
+ // A checkpoint replay is followed by one frame where transient live chrome
1351
+ // (status/footer rows) may be inserted inside the visible suffix and then
1352
+ // disappear; repaint it in place so it never enters scrollback. If the
1353
+ // insertion grows the overflow boundary, native history would lose rows
1354
+ // while the viewport looks correct, so rebuild instead.
1355
+ const appendedTailStart = this.#findAppendedTailStart(newLines);
1356
+ const overflowBefore = Math.max(0, this.#previousLines.length - height);
1357
+ const overflowAfter = Math.max(0, newLines.length - height);
1358
+ if (
1359
+ appendedTailStart === newLines.length &&
1360
+ diff.firstChanged >= prevViewportTop &&
1361
+ overflowAfter <= overflowBefore
1362
+ ) {
1363
+ return { kind: "viewportRepaint" };
1364
+ }
1365
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1366
+ if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1367
+ return { kind: "historyRebuild" };
1368
+ }
1369
+ this.#markNativeScrollbackDirty();
1300
1370
  return { kind: "viewportRepaint" };
1301
1371
  }
1302
1372
 
1303
1373
  if (diff.firstChanged === -1) {
1304
- // Content unchanged. Width change still alters wrapping geometry;
1305
- // height change shifts the visible window. Either needs a repaint
1306
- // (outside hostile environments).
1374
+ // Content unchanged. A forced render still needs to refresh the visible
1375
+ // viewport, but it must keep the existing diff basis so later coalesced
1376
+ // content mutations can still update native scrollback correctly.
1377
+ if (forceViewportRepaint) return { kind: "viewportRepaint" };
1378
+ // Width change still alters wrapping geometry; height change shifts the
1379
+ // visible window. Either needs a repaint (outside hostile environments).
1307
1380
  if (widthChanged) return { kind: "viewportRepaint" };
1308
1381
  if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1309
1382
  return { kind: "noop" };
@@ -1328,29 +1401,47 @@ export class TUI extends Container {
1328
1401
  const contentGrew = newLines.length > this.#previousLines.length;
1329
1402
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1330
1403
  const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
1404
+ if (pureAppend && contentGrew && this.#previousLines.length > height && !isMultiplexerSession()) {
1405
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1406
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1407
+ this.#markNativeScrollbackDirty();
1408
+ return { kind: "deferredMutation" };
1409
+ }
1410
+ }
1331
1411
  if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1332
1412
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1333
1413
  if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1334
1414
  this.#markNativeScrollbackDirty();
1335
1415
  return { kind: "deferredMutation" };
1336
1416
  }
1337
- // Expanding a collapsed offscreen cell inserts rows before an unchanged
1338
- // suffix. A viewport-only repaint makes the live bottom look correct but
1339
- // leaves native scrollback holding the old collapsed rows; scrolling up then
1340
- // shows a splice of stale history and the new tail. Pure tail appends with an
1341
- // offscreen status/header tick are still handled by the append-tail path.
1417
+ // The append-tail path can only scroll a clean pure-tail append over an
1418
+ // offscreen edit into history: the rows it pushes must equal the net
1419
+ // growth, i.e. `#findAppendedTailStart` must land on `previousLines.length`
1420
+ // (`tailAppendCount === addedCount`). Any mismatch is structurally
1421
+ // ambiguous more added than the matched tail means offscreen rows were
1422
+ // inserted (a collapsed cell expanding); fewer means the previous last
1423
+ // line repeats earlier so the tail is mis-located. Under-counting splices
1424
+ // stale history; over-counting scrolls an extra row and duplicates the
1425
+ // line at the viewport top. Rebuild whenever the replay checkpoint allows.
1342
1426
  if (
1343
1427
  contentGrew &&
1344
1428
  diff.firstChanged < prevViewportTop &&
1345
- this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, false)
1429
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)
1346
1430
  ) {
1347
1431
  const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
1348
1432
  const tailAppendCount = newLines.length - appendedTailStart;
1349
1433
  const addedCount = newLines.length - this.#previousLines.length;
1350
- if (addedCount > tailAppendCount) {
1434
+ if (addedCount !== tailAppendCount) {
1351
1435
  return { kind: "historyRebuild" };
1352
1436
  }
1353
1437
  }
1438
+ if (
1439
+ newLines.length !== this.#previousLines.length &&
1440
+ this.#scrollbackHighWater > 0 &&
1441
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1442
+ ) {
1443
+ return { kind: "historyRebuild" };
1444
+ }
1354
1445
  }
1355
1446
 
1356
1447
  // Height changes shift the visible window. Repaint when content didn't
@@ -1360,6 +1451,17 @@ export class TUI extends Container {
1360
1451
  return { kind: "viewportRepaint" };
1361
1452
  }
1362
1453
 
1454
+ // A height change that also grew the content into a frame that now fits
1455
+ // entirely on screen cannot use the diff or append-tail emitters below:
1456
+ // both position scrolled rows against the previous viewport top and
1457
+ // hardware cursor row, which the reflow just invalidated, so the appended
1458
+ // tail lands `height`-delta rows too low. With no overflow there is no
1459
+ // native scrollback to preserve, so repaint the viewport at the new
1460
+ // geometry. (Height changes with overflow keep the existing deferral.)
1461
+ if (heightChanged && newLines.length <= height && !isTermuxSession() && !isMultiplexerSession()) {
1462
+ return { kind: "viewportRepaint" };
1463
+ }
1464
+
1363
1465
  // Configurable shrink-clear: opt-in path that repaints to wipe rows the
1364
1466
  // diff path would leave behind.
1365
1467
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
@@ -1371,11 +1473,33 @@ export class TUI extends Container {
1371
1473
  return { kind: "shrink" };
1372
1474
  }
1373
1475
 
1374
- // Offscreen edit: viewport repaint corrects shifted rows when the native
1375
- // viewport is at the tail. Scrolled native-history cases are deferred above.
1476
+ // Offscreen edit: repainting only the viewport leaves native history stale
1477
+ // while the user is bottom-anchored. Rebuild whenever replay is safe. If
1478
+ // replay is not safe, keep the viewport stable, mark history dirty, and only
1479
+ // scroll a clean appended tail so newly streamed rows remain reachable until
1480
+ // the next checkpoint rebuild.
1376
1481
  if (diff.firstChanged < prevViewportTop) {
1377
- const appendFrom = diff.appendedLines ? this.#findAppendedTailStart(newLines) : undefined;
1378
- return { kind: "viewportRepaint", appendFrom };
1482
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1483
+ const cleanTailAppend =
1484
+ diff.appendedLines && this.#findAppendedTailStart(newLines) === this.#previousLines.length;
1485
+ if (
1486
+ !isMultiplexerSession() &&
1487
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1488
+ ) {
1489
+ return { kind: "historyRebuild" };
1490
+ }
1491
+ this.#markNativeScrollbackDirty();
1492
+ return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
1493
+ }
1494
+
1495
+ if (forceViewportRepaint) {
1496
+ if (isMultiplexerSession()) return { kind: "viewportRepaint" };
1497
+ if (pureAppend && contentGrew && this.#previousLines.length >= height) {
1498
+ return { kind: "viewportRepaint", appendFrom: this.#previousLines.length };
1499
+ }
1500
+ if (newLines.length === this.#previousLines.length && diff.firstChanged >= prevViewportTop) {
1501
+ return { kind: "viewportRepaint" };
1502
+ }
1379
1503
  }
1380
1504
 
1381
1505
  return {
@@ -1477,6 +1601,30 @@ export class TUI extends Container {
1477
1601
  );
1478
1602
  }
1479
1603
 
1604
+ /**
1605
+ * Live-frame counterpart to {@link #canReplayNativeScrollbackAtCheckpoint}.
1606
+ * Decides whether a destructive native scrollback rebuild
1607
+ * (`historyRebuild`/`overlayRebuild`, which clear scrollback and snap the
1608
+ * viewport to the tail) is safe to emit *during ordinary rendering*. POSIX
1609
+ * terminals cannot report whether the user has scrolled up
1610
+ * (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
1611
+ * treated as unsafe: defer to a non-destructive viewport repaint, mark
1612
+ * scrollback dirty, and reconcile history at the next explicit checkpoint
1613
+ * ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the
1614
+ * editor keystroke has already pinned the terminal to the bottom. Without
1615
+ * this, every offscreen transcript edit while streaming wiped scrollback and
1616
+ * yanked a scrolled-up reader back down. `allowUnknownViewportMutation`
1617
+ * (autocomplete/IME) opts directly user-driven frames back into the rebuild.
1618
+ * Unlike the checkpoint predicate this carries no `process.platform`
1619
+ * optimism — resize and checkpoint replays keep using that one.
1620
+ */
1621
+ #canRebuildNativeScrollbackLive(
1622
+ nativeViewportAtBottom: boolean | undefined,
1623
+ allowUnknownViewportMutation: boolean,
1624
+ ): boolean {
1625
+ return nativeViewportAtBottom === true || (nativeViewportAtBottom === undefined && allowUnknownViewportMutation);
1626
+ }
1627
+
1480
1628
  #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
1481
1629
  if (lines.length >= paddedLength) return lines;
1482
1630
  return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
@@ -1488,6 +1636,13 @@ export class TUI extends Container {
1488
1636
  * `truncateToWidth` drops the trailing bytes appended by
1489
1637
  * {@link #applyLineResets}.
1490
1638
  */
1639
+ #fitLinesToWidth(lines: string[], width: number): string[] {
1640
+ for (let i = 0; i < lines.length; i++) {
1641
+ lines[i] = this.#fitLineToWidth(lines[i], width);
1642
+ }
1643
+ return lines;
1644
+ }
1645
+
1491
1646
  #fitLineToWidth(line: string, width: number): string {
1492
1647
  if (TERMINAL.isImageLine(line)) return line;
1493
1648
  if (visibleWidth(line) <= width) return line;
@@ -1501,6 +1656,7 @@ export class TUI extends Container {
1501
1656
  */
1502
1657
  #commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
1503
1658
  this.#previousLines = lines;
1659
+ this.#forceViewportRepaintOnNextRender = false;
1504
1660
  this.#previousWidth = width;
1505
1661
  this.#previousHeight = height;
1506
1662
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1526,7 +1682,7 @@ export class TUI extends Container {
1526
1682
  }
1527
1683
  for (let i = 0; i < lines.length; i++) {
1528
1684
  if (i > 0) buffer += "\r\n";
1529
- buffer += lines[i];
1685
+ buffer += this.#fitLineToWidth(lines[i], width);
1530
1686
  }
1531
1687
  const finalRow = Math.max(0, lines.length - 1);
1532
1688
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
@@ -1592,6 +1748,7 @@ export class TUI extends Container {
1592
1748
  lines: string[],
1593
1749
  start: number,
1594
1750
  height: number,
1751
+ width: number,
1595
1752
  prevViewportTop: number,
1596
1753
  prevHardwareCursorRow: number,
1597
1754
  ): void {
@@ -1606,7 +1763,7 @@ export class TUI extends Container {
1606
1763
  if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
1607
1764
  for (let i = start; i < lines.length; i++) {
1608
1765
  buffer += "\r\n";
1609
- buffer += lines[i];
1766
+ buffer += this.#fitLineToWidth(lines[i], width);
1610
1767
  }
1611
1768
  buffer += PAINT_END;
1612
1769
  this.terminal.write(buffer);