@oh-my-pi/pi-tui 15.5.11 → 15.5.13

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.12] - 2026-05-29
6
+
7
+ ### Fixed
8
+
9
+ - Fixed terminal resizes corrupting native scrollback with duplicated rows. The 15.4.0 change that defers a destructive scrollback clear+replay (so a user scrolled into history is not yanked while a streaming tail cell mutates) also caught genuine width/height resizes: a resize reflows the terminal's own committed scrollback at the new geometry, but repainting only the viewport left the stale old-size rows in history, so every overflowed row showed up twice (old-size wrap + new-size copy) when scrolling back, until the next prompt submit cleaned it up. `#planRender` now rebuilds history synchronously when the frame's geometry actually changed (`widthChanged || heightChanged`) via the restored `historyRebuild` intent, and defers the rebuild only for pure content mutations where the user may be reading scrollback mid-stream.
10
+
5
11
  ## [15.5.0] - 2026-05-26
6
12
 
7
13
  ### Fixed
@@ -162,5 +162,11 @@ export declare class TUI extends Container {
162
162
  addInputListener(listener: InputListener): () => void;
163
163
  removeInputListener(listener: InputListener): void;
164
164
  stop(): void;
165
+ /**
166
+ * Rebuild native terminal scrollback if live rendering deferred a history rewrite.
167
+ * Callers should only invoke this at checkpoints where the user is expected to be
168
+ * at the terminal bottom, such as after submitting a new prompt.
169
+ */
170
+ refreshNativeScrollbackIfDirty(): boolean;
165
171
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
166
172
  }
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.5.11",
4
+ "version": "15.5.13",
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.5.11",
41
- "@oh-my-pi/pi-utils": "15.5.11",
40
+ "@oh-my-pi/pi-natives": "15.5.13",
41
+ "@oh-my-pi/pi-utils": "15.5.13",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
package/src/tui.ts CHANGED
@@ -236,8 +236,9 @@ export class Container implements Component {
236
236
  * - `initial`: first paint after `start()` — clear viewport, emit transcript.
237
237
  * - `sessionReplace`: caller asked for `{ clearScrollback: true }` on a forced
238
238
  * render — clear viewport, clear scrollback (outside multiplexers).
239
- * - `historyRebuild`: width changed and offscreen rows changed clear viewport
240
- * and scrollback so terminal history rewraps at the new width.
239
+ * - `historyRebuild`: a geometry change (terminal resize) left native history
240
+ * wrapped at the old size — clear viewport and scrollback so it rewraps at the
241
+ * new geometry. Also flushes deferred content-only rewrites.
241
242
  * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
242
243
  * is set, emit those tail rows as scrollback growth first so streaming
243
244
  * output reaches terminal history before the corrected viewport is drawn.
@@ -290,6 +291,7 @@ export class TUI extends Container {
290
291
  // Set after a clear+full replay so the next insert-above-suffix frame does
291
292
  // not scroll replayed live chrome (status/editor) into fresh history.
292
293
  #suppressNextSuffixScroll = false;
294
+ #nativeScrollbackDirty = false;
293
295
  #fullRedrawCount = 0;
294
296
  #clearScrollbackOnNextRender = false;
295
297
  #hasEverRendered = false;
@@ -627,20 +629,23 @@ export class TUI extends Container {
627
629
  this.terminal.stop();
628
630
  }
629
631
 
632
+ /**
633
+ * Rebuild native terminal scrollback if live rendering deferred a history rewrite.
634
+ * Callers should only invoke this at checkpoints where the user is expected to be
635
+ * at the terminal bottom, such as after submitting a new prompt.
636
+ */
637
+ refreshNativeScrollbackIfDirty(): boolean {
638
+ if (!this.#nativeScrollbackDirty || this.#stopped) return false;
639
+ this.#prepareForcedRender(true);
640
+ this.#renderRequested = false;
641
+ this.#lastRenderAt = performance.now();
642
+ this.#doRender();
643
+ return true;
644
+ }
645
+
630
646
  requestRender(force = false, options?: RenderRequestOptions): void {
631
647
  if (force) {
632
- this.#clearScrollbackOnNextRender ||= options?.clearScrollback === true;
633
- this.#previousLines = [];
634
- this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
635
- this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
636
- this.#cursorRow = 0;
637
- this.#hardwareCursorRow = 0;
638
- this.#viewportTopRow = 0;
639
- this.#maxLinesRendered = 0;
640
- if (this.#renderTimer) {
641
- clearTimeout(this.#renderTimer);
642
- this.#renderTimer = undefined;
643
- }
648
+ this.#prepareForcedRender(options?.clearScrollback === true);
644
649
  this.#renderRequested = true;
645
650
  process.nextTick(() => {
646
651
  if (this.#stopped || !this.#renderRequested) {
@@ -657,6 +662,21 @@ export class TUI extends Container {
657
662
  process.nextTick(() => this.#scheduleRender());
658
663
  }
659
664
 
665
+ #prepareForcedRender(clearScrollback: boolean): void {
666
+ this.#clearScrollbackOnNextRender ||= clearScrollback;
667
+ this.#previousLines = [];
668
+ this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
669
+ this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
670
+ this.#cursorRow = 0;
671
+ this.#hardwareCursorRow = 0;
672
+ this.#viewportTopRow = 0;
673
+ this.#maxLinesRendered = 0;
674
+ if (this.#renderTimer) {
675
+ clearTimeout(this.#renderTimer);
676
+ this.#renderTimer = undefined;
677
+ }
678
+ }
679
+
660
680
  #scheduleRender(): void {
661
681
  if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
662
682
  return;
@@ -1111,12 +1131,14 @@ export class TUI extends Container {
1111
1131
  return;
1112
1132
  case "sessionReplace":
1113
1133
  this.#clearScrollbackOnNextRender = false;
1134
+ this.#nativeScrollbackDirty = false;
1114
1135
  this.#emitFullPaint(lines, width, height, cursorPos, {
1115
1136
  clearViewport: true,
1116
1137
  clearScrollback: !isMultiplexerSession(),
1117
1138
  });
1118
1139
  return;
1119
1140
  case "historyRebuild":
1141
+ this.#nativeScrollbackDirty = false;
1120
1142
  this.#emitFullPaint(lines, width, height, cursorPos, {
1121
1143
  clearViewport: true,
1122
1144
  clearScrollback: !isMultiplexerSession(),
@@ -1149,9 +1171,11 @@ export class TUI extends Container {
1149
1171
 
1150
1172
  /**
1151
1173
  * Map the current frame onto a single render intent. Order matters: forced
1152
- * resets and session replacement short-circuit before any diff work, and
1153
- * width-changed-with-offscreen edits route to {@link "historyRebuild"} so
1154
- * terminal scrollback receives the new geometry.
1174
+ * resets and session replacement short-circuit before any diff work. A real
1175
+ * resize (geometry change) that invalidates native scrollback rebuilds it now;
1176
+ * a pure content mutation that does the same marks scrollback dirty and
1177
+ * repaints only the viewport, deferring the destructive clear+replay to an
1178
+ * explicit checkpoint so users scrolled into history are not yanked.
1155
1179
  */
1156
1180
  #planRender(
1157
1181
  newLines: string[],
@@ -1175,11 +1199,11 @@ export class TUI extends Container {
1175
1199
 
1176
1200
  const diff = this.#diffLines(newLines);
1177
1201
 
1178
- // Shrink-across-viewport-boundary: if a shrink would place the new
1179
- // viewport above rows already committed to terminal scrollback, those
1180
- // rows would appear twice when the user scrolls back. A clear+replay
1181
- // keeps the current OMP transcript scrollable while dropping stale
1182
- // terminal history.
1202
+ // Shrink across the viewport boundary: the new transcript would re-expose
1203
+ // rows already committed to native scrollback. A real resize already
1204
+ // reflowed history, so rebuild it now; a pure content shrink (e.g. a
1205
+ // streaming tail cell collapsing) defers the clear+replay so a user
1206
+ // scrolled into history is not yanked to the bottom mid-stream.
1183
1207
  const naturalViewportTop = Math.max(0, newLines.length - height);
1184
1208
  if (
1185
1209
  diff.firstChanged !== -1 &&
@@ -1187,7 +1211,9 @@ export class TUI extends Container {
1187
1211
  naturalViewportTop < this.#scrollbackHighWater &&
1188
1212
  !isMultiplexerSession()
1189
1213
  ) {
1190
- return { kind: "historyRebuild" };
1214
+ if (widthChanged || heightChanged) return { kind: "historyRebuild" };
1215
+ this.#nativeScrollbackDirty = true;
1216
+ return { kind: "viewportRepaint" };
1191
1217
  }
1192
1218
 
1193
1219
  const suppressSuffixScroll = this.#suppressNextSuffixScroll;
@@ -1210,10 +1236,10 @@ export class TUI extends Container {
1210
1236
  return { kind: "noop" };
1211
1237
  }
1212
1238
 
1213
- // Width changes alter wrapping for the whole transcript. Offscreen
1214
- // edits need a history rebuild so terminal scrollback receives the
1215
- // new geometry; pure appends fall through to the diff path so the
1216
- // append handler scrolls them into scrollback correctly.
1239
+ // Width changes rewrap the whole transcript. An offscreen edit leaves
1240
+ // native history at the old width, so rebuild it now the terminal already
1241
+ // reflowed and the user is at the terminal to resize. Pure appends fall
1242
+ // through to the diff path so the append handler scrolls them into history.
1217
1243
  if (widthChanged) {
1218
1244
  if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
1219
1245
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;