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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.7.5] - 2026-06-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed native Windows + Windows Terminal scrollback being yanked to the top when a streaming response triggered a TUI full redraw. Under ConPTY the `kernel32` `GetConsoleScreenBufferInfo` probe answers about the pseudo-console (always at the buffer tail) and not about WT's host scrollback, so `isNativeViewportAtBottom()` falsely returned `true` while the user was scrolled up and the shrink-across-viewport branch issued a destructive `historyRebuild` (`\x1b[2J\x1b[H\x1b[3J`). The probe now short-circuits to `undefined` whenever `WT_SESSION` is set, letting the existing deferred-rebuild path keep streaming-time mutations non-destructive and reconcile native history at the next prompt-submit checkpoint. ([#1635](https://github.com/can1357/oh-my-pi/issues/1635))
10
+
11
+ ## [15.7.3] - 2026-05-31
12
+
13
+ ### Added
14
+
15
+ - Added `overflowSearch` to `SelectListLayoutOptions` to let consumers enable or disable type-to-filter search and search-status rendering per SelectList instance
16
+ - Added fuzzy type-to-filter search to overflowing `SelectList` pickers, with search status and result counts.
17
+ - Added `TUI.setEagerNativeScrollbackRebuild(enabled)` — while enabled, live render frames rebuild native scrollback on offscreen/structural changes even when the viewport position is unobservable (POSIX), instead of deferring to a non-destructive repaint. Trades the anti-yank guarantee for clean, duplicate-free history; intended for windows where output above the fold is actively re-laying out (e.g. a tool whose result is still streaming). A terminal that reports a known-scrolled viewport still defers.
18
+
19
+ ### Changed
20
+
21
+ - Disabled interactive search filtering for editor autocomplete and slash-command `SelectList`s by passing `overflowSearch: false` in their layout options
22
+
23
+ ### Fixed
24
+
25
+ - Preserved hidden tmux overlays in the live viewport by removing overlay content from view when an overlay was hidden while keeping pane history intact
26
+ - Preserved native scrollback when forced TUI renders coalesce with content growth, and deferred pure tail appends while readers are scrolled into history.
27
+ - Preserved existing terminal scrollback during forced and structural TUI renders so preexisting shell lines remained visible after component mutations
28
+ - Rebuilt native scrollback for safe bottom-anchored offscreen edits and high-water preview collapses instead of repainting only the viewport, preventing stale or duplicated rows above the live viewport.
29
+ - Stripped internal cursor marker sentinels from all rendered lines so offscreen focus markers no longer leak into terminal output
30
+ - Truncated all painted lines to terminal width during viewport repaints and append-tail updates so long content no longer overflows or wraps unexpectedly
31
+ - Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown
32
+ - Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer.
33
+ - Fixed scrolled-up readers being yanked back to the tail whenever streaming content arrived on POSIX terminals (macOS/Linux). Native viewport position is unobservable there (`isNativeViewportAtBottom()` returns `undefined`), and the planner optimistically treated "unknown" as "at bottom", so every offscreen streaming edit ran a destructive `historyRebuild` that cleared scrollback and snapped the view to the bottom. Live render frames now treat an unknown viewport as unsafe for a destructive rebuild — they defer to a non-destructive viewport repaint and reconcile native scrollback at the next explicit checkpoint (prompt submit). Resize and checkpoint replays keep the prior behavior.
34
+ - Fixed native scrollback not rewrapping when the terminal widens on POSIX. A width increase reflows the transcript to fewer lines, which the shrink-across-boundary branch intercepted and (after the unknown-viewport deferral) repainted only the viewport — leaving committed history wrapped at the old width and duplicated above the live viewport. Width changes now rebuild native scrollback at the new geometry even when the viewport position is unknown (a yank is acceptable on an explicit resize); a terminal that can report a scrolled viewport still defers.
35
+
5
36
  ## [15.7.0] - 2026-05-31
6
37
 
7
38
  ### Fixed
@@ -26,6 +26,8 @@ export interface SelectListLayoutOptions {
26
26
  minPrimaryColumnWidth?: number;
27
27
  maxPrimaryColumnWidth?: number;
28
28
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
29
+ /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
30
+ overflowSearch?: boolean;
29
31
  }
30
32
  export declare class SelectList implements Component {
31
33
  #private;
@@ -41,6 +41,21 @@ export interface Terminal {
41
41
  /** The last detected terminal appearance, or undefined if not yet known. */
42
42
  get appearance(): TerminalAppearance | undefined;
43
43
  }
44
+ /**
45
+ * Whether the native console viewport-position probe should be consulted.
46
+ *
47
+ * Returns `true` only on native Windows that is *not* fronted by Windows
48
+ * Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
49
+ * ConPTY pseudo-console — which is always pinned to its tail — and not about
50
+ * the user-visible scrollback in modern hosts. Treat any such host as
51
+ * unreportable so the renderer falls back to the deferred-rebuild path.
52
+ *
53
+ * Pure helper for unit testing; the runtime call site reads `$env` /
54
+ * `process.platform`. See #1635.
55
+ */
56
+ export declare function shouldTrustNativeViewportProbe(env?: {
57
+ WT_SESSION?: string | undefined;
58
+ }, platform?: NodeJS.Platform): boolean;
44
59
  /**
45
60
  * Real terminal using process.stdin/stdout
46
61
  */
@@ -53,6 +68,15 @@ export declare class ProcessTerminal implements Terminal {
53
68
  /**
54
69
  * Returns true when Windows' active console viewport is at the scrollback tail.
55
70
  * POSIX terminals do not expose native scrollback position through a standard API.
71
+ *
72
+ * On native Windows running under Windows Terminal (the default modern
73
+ * host), the `kernel32` probe answers about the ConPTY pseudo-console — not
74
+ * the user-visible WT viewport — so it would always read "at bottom" while
75
+ * the user is scrolled up. Return `undefined` there so the renderer falls
76
+ * back to the POSIX-style deferred-rebuild path: streaming mutations stay
77
+ * non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
78
+ * checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
79
+ * is already pinned to the bottom by the editor keystroke. See #1635.
56
80
  */
57
81
  isNativeViewportAtBottom(): boolean | undefined;
58
82
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
@@ -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.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -32,13 +32,13 @@
32
32
  "check": "biome check . && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
34
34
  "lint": "biome lint .",
35
- "test": "bun test test/*.test.ts",
35
+ "test": "bun test --parallel test/*.test.ts",
36
36
  "fix": "biome check --write --unsafe .",
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.7.2",
41
- "@oh-my-pi/pi-utils": "15.7.2",
40
+ "@oh-my-pi/pi-natives": "15.7.5",
41
+ "@oh-my-pi/pi-utils": "15.7.5",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -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/terminal.ts CHANGED
@@ -113,6 +113,27 @@ function isWindowsSubsystemForLinux(): boolean {
113
113
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
114
114
  }
115
115
 
116
+ /**
117
+ * Whether the native console viewport-position probe should be consulted.
118
+ *
119
+ * Returns `true` only on native Windows that is *not* fronted by Windows
120
+ * Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
121
+ * ConPTY pseudo-console — which is always pinned to its tail — and not about
122
+ * the user-visible scrollback in modern hosts. Treat any such host as
123
+ * unreportable so the renderer falls back to the deferred-rebuild path.
124
+ *
125
+ * Pure helper for unit testing; the runtime call site reads `$env` /
126
+ * `process.platform`. See #1635.
127
+ */
128
+ export function shouldTrustNativeViewportProbe(
129
+ env: { WT_SESSION?: string | undefined } = $env,
130
+ platform: NodeJS.Platform = process.platform,
131
+ ): boolean {
132
+ if (platform !== "win32") return false;
133
+ if (env.WT_SESSION) return false;
134
+ return true;
135
+ }
136
+
116
137
  /**
117
138
  * Real terminal using process.stdin/stdout
118
139
  */
@@ -214,9 +235,18 @@ export class ProcessTerminal implements Terminal {
214
235
  /**
215
236
  * Returns true when Windows' active console viewport is at the scrollback tail.
216
237
  * POSIX terminals do not expose native scrollback position through a standard API.
238
+ *
239
+ * On native Windows running under Windows Terminal (the default modern
240
+ * host), the `kernel32` probe answers about the ConPTY pseudo-console — not
241
+ * the user-visible WT viewport — so it would always read "at bottom" while
242
+ * the user is scrolled up. Return `undefined` there so the renderer falls
243
+ * back to the POSIX-style deferred-rebuild path: streaming mutations stay
244
+ * non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
245
+ * checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
246
+ * is already pinned to the bottom by the editor keystroke. See #1635.
217
247
  */
218
248
  isNativeViewportAtBottom(): boolean | undefined {
219
- if (process.platform !== "win32") return undefined;
249
+ if (!shouldTrustNativeViewportProbe()) return undefined;
220
250
  try {
221
251
  const kernel32 = dlopen("kernel32.dll", {
222
252
  GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
package/src/tui.ts CHANGED
@@ -282,6 +282,7 @@ type RenderIntent =
282
282
  | { kind: "initial" }
283
283
  | { kind: "sessionReplace" }
284
284
  | { kind: "historyRebuild" }
285
+ | { kind: "overlayRebuild" }
285
286
  | { kind: "viewportRepaint"; appendFrom?: number }
286
287
  | { kind: "deferredShrink"; paddedLength: number }
287
288
  | { kind: "deferredMutation" }
@@ -328,7 +329,9 @@ export class TUI extends Container {
328
329
  #nativeScrollbackDirty = false;
329
330
  #fullRedrawCount = 0;
330
331
  #clearScrollbackOnNextRender = false;
332
+ #forceViewportRepaintOnNextRender = false;
331
333
  #allowUnknownViewportMutationOnNextRender = false;
334
+ #eagerNativeScrollbackRebuild = false;
332
335
  #hasEverRendered = false;
333
336
  #stopped = false;
334
337
 
@@ -376,6 +379,21 @@ export class TUI extends Container {
376
379
  this.#clearOnShrink = enabled;
377
380
  }
378
381
 
382
+ /**
383
+ * When enabled, live render frames rebuild native scrollback on offscreen and
384
+ * structural changes even when the viewport position is unobservable (POSIX,
385
+ * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
386
+ * non-destructive repaint. This trades the anti-yank guarantee for a clean,
387
+ * duplicate-free history and is meant for windows where output above the fold
388
+ * is actively re-rendering — e.g. a tool whose result is still streaming and
389
+ * re-laying-out rows that have already scrolled into history. A snap to the tail
390
+ * is acceptable there. A terminal that can report a *known*-scrolled viewport
391
+ * (Windows) still defers; only the unknown case is forced to rebuild.
392
+ */
393
+ setEagerNativeScrollbackRebuild(enabled: boolean): void {
394
+ this.#eagerNativeScrollbackRebuild = enabled;
395
+ }
396
+
379
397
  setFocus(component: Component | null): void {
380
398
  // Clear focused flag on old component
381
399
  if (isFocusable(this.#focusedComponent)) {
@@ -675,7 +693,7 @@ export class TUI extends Container {
675
693
  ) {
676
694
  return false;
677
695
  }
678
- this.#prepareForcedRender(true);
696
+ this.#prepareForcedRender(true, options?.allowUnknownViewport === true);
679
697
  this.#renderRequested = false;
680
698
  this.#lastRenderAt = performance.now();
681
699
  this.#doRender();
@@ -683,9 +701,10 @@ export class TUI extends Container {
683
701
  }
684
702
 
685
703
  requestRender(force = false, options?: RenderRequestOptions): void {
686
- 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) };
1107
1124
  }
1125
+ let stripped = line;
1126
+ while (markerIndex !== -1) {
1127
+ stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1128
+ markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1129
+ }
1130
+ lines[row] = stripped;
1108
1131
  }
1109
- return 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,18 +1320,57 @@ 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
- this.#markNativeScrollbackDirty();
1286
- if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
1287
- return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1338
+ // POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
1339
+ // viewport position fall through here (`canRebuildNativeScrollbackLive` is
1340
+ // false). A destructive rebuild emits `\x1b[3J`, which on modern terminals
1341
+ // resets the viewport to the top of scrollback and yanks a scrolled-up
1342
+ // reader (issue #1635), so it is unsafe while the probe is unavailable.
1343
+ //
1344
+ // When the shrunk transcript now fits entirely in the viewport there is no
1345
+ // new native history to preserve during the live frame: repaint the screen
1346
+ // in place (no `\x1b[3J`) and defer stale-scrollback cleanup to the next
1347
+ // checkpoint rebuild (e.g. prompt submit -> `refreshNativeScrollbackIfDirty`).
1348
+ if (nativeViewportAtBottom === undefined && newLines.length <= height) {
1349
+ this.#markNativeScrollbackDirty();
1350
+ return { kind: "viewportRepaint" };
1288
1351
  }
1289
- return { kind: "viewportRepaint" };
1352
+ // The shrunk transcript still overflows the viewport. A plain viewport
1353
+ // repaint would re-emit the rows between the new and old viewport tops on top
1354
+ // of the copies the terminal already kept in native scrollback; `deferredShrink`
1355
+ // pads to the previous row count so no committed row is re-emitted, and the
1356
+ // next checkpoint rebuild cleans up.
1357
+ //
1358
+ // That deferral only carries real content when `newLines.length` reaches the
1359
+ // padded viewport top (`previousLines.length - height`) — otherwise every row
1360
+ // the padded repaint draws is past the end of `newLines` and renders blank,
1361
+ // hiding the prompt until the next checkpoint. This can happen even when
1362
+ // `scrollbackHighWater` is far below `previousLines.length - height`, because
1363
+ // prior unknown-POSIX viewport repaints commit longer logical frames without
1364
+ // moving the native scrollback boundary. For a shrink that large a blank,
1365
+ // uninteractable viewport is the greater evil, so yank with `historyRebuild`.
1366
+ // Real win32 unknown probes defer as scrolled above and never reach this; the
1367
+ // yank only lands on non-win32 hosts whose probe is genuinely unavailable.
1368
+ const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
1369
+ if (newLines.length <= paddedViewportTop) {
1370
+ return { kind: "historyRebuild" };
1371
+ }
1372
+ this.#markNativeScrollbackDirty();
1373
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1290
1374
  }
1291
1375
 
1292
1376
  const suppressSuffixScroll = this.#suppressNextSuffixScroll;
@@ -1297,13 +1381,36 @@ export class TUI extends Container {
1297
1381
  diff.firstChanged < this.#previousLines.length &&
1298
1382
  !isMultiplexerSession()
1299
1383
  ) {
1384
+ // A checkpoint replay is followed by one frame where transient live chrome
1385
+ // (status/footer rows) may be inserted inside the visible suffix and then
1386
+ // disappear; repaint it in place so it never enters scrollback. If the
1387
+ // insertion grows the overflow boundary, native history would lose rows
1388
+ // while the viewport looks correct, so rebuild instead.
1389
+ const appendedTailStart = this.#findAppendedTailStart(newLines);
1390
+ const overflowBefore = Math.max(0, this.#previousLines.length - height);
1391
+ const overflowAfter = Math.max(0, newLines.length - height);
1392
+ if (
1393
+ appendedTailStart === newLines.length &&
1394
+ diff.firstChanged >= prevViewportTop &&
1395
+ overflowAfter <= overflowBefore
1396
+ ) {
1397
+ return { kind: "viewportRepaint" };
1398
+ }
1399
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1400
+ if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1401
+ return { kind: "historyRebuild" };
1402
+ }
1403
+ this.#markNativeScrollbackDirty();
1300
1404
  return { kind: "viewportRepaint" };
1301
1405
  }
1302
1406
 
1303
1407
  if (diff.firstChanged === -1) {
1304
- // Content unchanged. Width change still alters wrapping geometry;
1305
- // height change shifts the visible window. Either needs a repaint
1306
- // (outside hostile environments).
1408
+ // Content unchanged. A forced render still needs to refresh the visible
1409
+ // viewport, but it must keep the existing diff basis so later coalesced
1410
+ // content mutations can still update native scrollback correctly.
1411
+ if (forceViewportRepaint) return { kind: "viewportRepaint" };
1412
+ // Width change still alters wrapping geometry; height change shifts the
1413
+ // visible window. Either needs a repaint (outside hostile environments).
1307
1414
  if (widthChanged) return { kind: "viewportRepaint" };
1308
1415
  if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1309
1416
  return { kind: "noop" };
@@ -1328,29 +1435,47 @@ export class TUI extends Container {
1328
1435
  const contentGrew = newLines.length > this.#previousLines.length;
1329
1436
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1330
1437
  const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
1438
+ if (pureAppend && contentGrew && this.#previousLines.length > height && !isMultiplexerSession()) {
1439
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1440
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1441
+ this.#markNativeScrollbackDirty();
1442
+ return { kind: "deferredMutation" };
1443
+ }
1444
+ }
1331
1445
  if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1332
1446
  const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1333
1447
  if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1334
1448
  this.#markNativeScrollbackDirty();
1335
1449
  return { kind: "deferredMutation" };
1336
1450
  }
1337
- // 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.
1451
+ // The append-tail path can only scroll a clean pure-tail append over an
1452
+ // offscreen edit into history: the rows it pushes must equal the net
1453
+ // growth, i.e. `#findAppendedTailStart` must land on `previousLines.length`
1454
+ // (`tailAppendCount === addedCount`). Any mismatch is structurally
1455
+ // ambiguous more added than the matched tail means offscreen rows were
1456
+ // inserted (a collapsed cell expanding); fewer means the previous last
1457
+ // line repeats earlier so the tail is mis-located. Under-counting splices
1458
+ // stale history; over-counting scrolls an extra row and duplicates the
1459
+ // line at the viewport top. Rebuild whenever the replay checkpoint allows.
1342
1460
  if (
1343
1461
  contentGrew &&
1344
1462
  diff.firstChanged < prevViewportTop &&
1345
- this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, false)
1463
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)
1346
1464
  ) {
1347
1465
  const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
1348
1466
  const tailAppendCount = newLines.length - appendedTailStart;
1349
1467
  const addedCount = newLines.length - this.#previousLines.length;
1350
- if (addedCount > tailAppendCount) {
1468
+ if (addedCount !== tailAppendCount) {
1351
1469
  return { kind: "historyRebuild" };
1352
1470
  }
1353
1471
  }
1472
+ if (
1473
+ newLines.length !== this.#previousLines.length &&
1474
+ this.#scrollbackHighWater > 0 &&
1475
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1476
+ ) {
1477
+ return { kind: "historyRebuild" };
1478
+ }
1354
1479
  }
1355
1480
 
1356
1481
  // Height changes shift the visible window. Repaint when content didn't
@@ -1360,6 +1485,17 @@ export class TUI extends Container {
1360
1485
  return { kind: "viewportRepaint" };
1361
1486
  }
1362
1487
 
1488
+ // A height change that also grew the content into a frame that now fits
1489
+ // entirely on screen cannot use the diff or append-tail emitters below:
1490
+ // both position scrolled rows against the previous viewport top and
1491
+ // hardware cursor row, which the reflow just invalidated, so the appended
1492
+ // tail lands `height`-delta rows too low. With no overflow there is no
1493
+ // native scrollback to preserve, so repaint the viewport at the new
1494
+ // geometry. (Height changes with overflow keep the existing deferral.)
1495
+ if (heightChanged && newLines.length <= height && !isTermuxSession() && !isMultiplexerSession()) {
1496
+ return { kind: "viewportRepaint" };
1497
+ }
1498
+
1363
1499
  // Configurable shrink-clear: opt-in path that repaints to wipe rows the
1364
1500
  // diff path would leave behind.
1365
1501
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
@@ -1371,11 +1507,33 @@ export class TUI extends Container {
1371
1507
  return { kind: "shrink" };
1372
1508
  }
1373
1509
 
1374
- // Offscreen edit: viewport repaint corrects shifted rows when the native
1375
- // viewport is at the tail. Scrolled native-history cases are deferred above.
1510
+ // Offscreen edit: repainting only the viewport leaves native history stale
1511
+ // while the user is bottom-anchored. Rebuild whenever replay is safe. If
1512
+ // replay is not safe, keep the viewport stable, mark history dirty, and only
1513
+ // scroll a clean appended tail so newly streamed rows remain reachable until
1514
+ // the next checkpoint rebuild.
1376
1515
  if (diff.firstChanged < prevViewportTop) {
1377
- const appendFrom = diff.appendedLines ? this.#findAppendedTailStart(newLines) : undefined;
1378
- return { kind: "viewportRepaint", appendFrom };
1516
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1517
+ const cleanTailAppend =
1518
+ diff.appendedLines && this.#findAppendedTailStart(newLines) === this.#previousLines.length;
1519
+ if (
1520
+ !isMultiplexerSession() &&
1521
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
1522
+ ) {
1523
+ return { kind: "historyRebuild" };
1524
+ }
1525
+ this.#markNativeScrollbackDirty();
1526
+ return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
1527
+ }
1528
+
1529
+ if (forceViewportRepaint) {
1530
+ if (isMultiplexerSession()) return { kind: "viewportRepaint" };
1531
+ if (pureAppend && contentGrew && this.#previousLines.length >= height) {
1532
+ return { kind: "viewportRepaint", appendFrom: this.#previousLines.length };
1533
+ }
1534
+ if (newLines.length === this.#previousLines.length && diff.firstChanged >= prevViewportTop) {
1535
+ return { kind: "viewportRepaint" };
1536
+ }
1379
1537
  }
1380
1538
 
1381
1539
  return {
@@ -1477,6 +1635,30 @@ export class TUI extends Container {
1477
1635
  );
1478
1636
  }
1479
1637
 
1638
+ /**
1639
+ * Live-frame counterpart to {@link #canReplayNativeScrollbackAtCheckpoint}.
1640
+ * Decides whether a destructive native scrollback rebuild
1641
+ * (`historyRebuild`/`overlayRebuild`, which clear scrollback and snap the
1642
+ * viewport to the tail) is safe to emit *during ordinary rendering*. POSIX
1643
+ * terminals cannot report whether the user has scrolled up
1644
+ * (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
1645
+ * treated as unsafe: defer to a non-destructive viewport repaint, mark
1646
+ * scrollback dirty, and reconcile history at the next explicit checkpoint
1647
+ * ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the
1648
+ * editor keystroke has already pinned the terminal to the bottom. Without
1649
+ * this, every offscreen transcript edit while streaming wiped scrollback and
1650
+ * yanked a scrolled-up reader back down. `allowUnknownViewportMutation`
1651
+ * (autocomplete/IME) opts directly user-driven frames back into the rebuild.
1652
+ * Unlike the checkpoint predicate this carries no `process.platform`
1653
+ * optimism — resize and checkpoint replays keep using that one.
1654
+ */
1655
+ #canRebuildNativeScrollbackLive(
1656
+ nativeViewportAtBottom: boolean | undefined,
1657
+ allowUnknownViewportMutation: boolean,
1658
+ ): boolean {
1659
+ return nativeViewportAtBottom === true || (nativeViewportAtBottom === undefined && allowUnknownViewportMutation);
1660
+ }
1661
+
1480
1662
  #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
1481
1663
  if (lines.length >= paddedLength) return lines;
1482
1664
  return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
@@ -1488,6 +1670,13 @@ export class TUI extends Container {
1488
1670
  * `truncateToWidth` drops the trailing bytes appended by
1489
1671
  * {@link #applyLineResets}.
1490
1672
  */
1673
+ #fitLinesToWidth(lines: string[], width: number): string[] {
1674
+ for (let i = 0; i < lines.length; i++) {
1675
+ lines[i] = this.#fitLineToWidth(lines[i], width);
1676
+ }
1677
+ return lines;
1678
+ }
1679
+
1491
1680
  #fitLineToWidth(line: string, width: number): string {
1492
1681
  if (TERMINAL.isImageLine(line)) return line;
1493
1682
  if (visibleWidth(line) <= width) return line;
@@ -1501,6 +1690,7 @@ export class TUI extends Container {
1501
1690
  */
1502
1691
  #commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
1503
1692
  this.#previousLines = lines;
1693
+ this.#forceViewportRepaintOnNextRender = false;
1504
1694
  this.#previousWidth = width;
1505
1695
  this.#previousHeight = height;
1506
1696
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1526,7 +1716,7 @@ export class TUI extends Container {
1526
1716
  }
1527
1717
  for (let i = 0; i < lines.length; i++) {
1528
1718
  if (i > 0) buffer += "\r\n";
1529
- buffer += lines[i];
1719
+ buffer += this.#fitLineToWidth(lines[i], width);
1530
1720
  }
1531
1721
  const finalRow = Math.max(0, lines.length - 1);
1532
1722
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
@@ -1592,6 +1782,7 @@ export class TUI extends Container {
1592
1782
  lines: string[],
1593
1783
  start: number,
1594
1784
  height: number,
1785
+ width: number,
1595
1786
  prevViewportTop: number,
1596
1787
  prevHardwareCursorRow: number,
1597
1788
  ): void {
@@ -1606,7 +1797,7 @@ export class TUI extends Container {
1606
1797
  if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
1607
1798
  for (let i = start; i < lines.length; i++) {
1608
1799
  buffer += "\r\n";
1609
- buffer += lines[i];
1800
+ buffer += this.#fitLineToWidth(lines[i], width);
1610
1801
  }
1611
1802
  buffer += PAINT_END;
1612
1803
  this.terminal.write(buffer);