@code-yeongyu/senpi-tui 2026.10.10-4 → 2026.10.10-6

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/dist/tui.d.ts CHANGED
@@ -466,6 +466,16 @@ export declare abstract class TuiBase extends Container {
466
466
  setTerminalColorSchemeNotifications(enabled: boolean): void;
467
467
  private queryCellSize;
468
468
  stop(options?: TuiStopOptions): void;
469
+ /**
470
+ * While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
471
+ * (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
472
+ * on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
473
+ * report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
474
+ * surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
475
+ */
476
+ setScrollbackReplayHold(hold: boolean | "until-input"): void;
477
+ /** Replays the scrollback once if a held frame left rows above the viewport stale. */
478
+ catchUpScrollback(): void;
469
479
  renderNow(force?: boolean): void;
470
480
  requestRender(force?: boolean, source?: string): void;
471
481
  /**
@@ -539,6 +549,14 @@ export declare abstract class TuiBase extends Container {
539
549
  private createViewportInsertScrollPlan;
540
550
  private renderViewportInsertScroll;
541
551
  private renderScrollbackReplay;
552
+ /**
553
+ * The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
554
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
555
+ * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
556
+ * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
557
+ * involved, so the caller replays as before.
558
+ */
559
+ private renderHeldRepaint;
542
560
  private renderMuxViewportRepaint;
543
561
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
544
562
  private compositeLineAt;
package/dist/tui.js CHANGED
@@ -627,6 +627,10 @@ export class TuiBase extends Container {
627
627
  /** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
628
628
  #minRenderIntervalMs;
629
629
  #lastCursorVisibility;
630
+ #holdScrollbackReplay;
631
+ #releaseHoldOnInput;
632
+ #scrollbackStale;
633
+ #scrollbackCatchUpPending;
630
634
  get hasOverlayEntries() {
631
635
  return this.overlayStack.length > 0;
632
636
  }
@@ -663,6 +667,10 @@ export class TuiBase extends Container {
663
667
  this.pendingTerminalColorQueries = [];
664
668
  this.terminalColorSchemeListeners = new Set();
665
669
  this.terminalColorSchemeNotificationsEnabled = false;
670
+ this.#holdScrollbackReplay = false;
671
+ this.#releaseHoldOnInput = false;
672
+ this.#scrollbackStale = false;
673
+ this.#scrollbackCatchUpPending = false;
666
674
  // Overlay stack for modal components rendered on top of base content
667
675
  this.focusOrderCounter = 0;
668
676
  this.overlayStack = [];
@@ -1260,6 +1268,10 @@ export class TuiBase extends Container {
1260
1268
  }
1261
1269
  stop(options = {}) {
1262
1270
  this.stopped = true;
1271
+ // The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
1272
+ // for an external editor or a suspend in the middle of a turn must not drop it.
1273
+ this.#scrollbackStale = false;
1274
+ this.#scrollbackCatchUpPending = false;
1263
1275
  this.renderRequested = false;
1264
1276
  this.inputRenderPending = false;
1265
1277
  this.cancelRenderTimer();
@@ -1303,6 +1315,25 @@ export class TuiBase extends Container {
1303
1315
  this.maxLinesRendered = 0;
1304
1316
  this.previousViewportTop = 0;
1305
1317
  }
1318
+ /**
1319
+ * While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
1320
+ * (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
1321
+ * on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
1322
+ * report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
1323
+ * surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
1324
+ */
1325
+ setScrollbackReplayHold(hold) {
1326
+ this.#holdScrollbackReplay = hold !== false;
1327
+ this.#releaseHoldOnInput = hold === "until-input";
1328
+ }
1329
+ /** Replays the scrollback once if a held frame left rows above the viewport stale. */
1330
+ catchUpScrollback() {
1331
+ if (!this.#scrollbackStale)
1332
+ return;
1333
+ this.#scrollbackStale = false;
1334
+ this.#scrollbackCatchUpPending = true;
1335
+ this.requestRender();
1336
+ }
1306
1337
  renderNow(force = false) {
1307
1338
  if (force)
1308
1339
  this.resetForcedRenderState();
@@ -1366,6 +1397,7 @@ export class TuiBase extends Container {
1366
1397
  }
1367
1398
  /** Drop every cached frame so the next render repaints from a clean slate. */
1368
1399
  resetForcedRenderState() {
1400
+ this.#scrollbackCatchUpPending = false;
1369
1401
  this.resetRenderState();
1370
1402
  this.dropPreviousLines();
1371
1403
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -1436,6 +1468,15 @@ export class TuiBase extends Container {
1436
1468
  if (this.consumeTerminalColorSchemeReport(data)) {
1437
1469
  return;
1438
1470
  }
1471
+ // A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
1472
+ // DEC private reports, window and cell-size reports) are not the user and must not trigger it.
1473
+ if (!isTerminalReport(data)) {
1474
+ if (this.#releaseHoldOnInput) {
1475
+ this.#releaseHoldOnInput = false;
1476
+ this.#holdScrollbackReplay = false;
1477
+ }
1478
+ this.catchUpScrollback();
1479
+ }
1439
1480
  if (this.inputListeners.size > 0) {
1440
1481
  let current = data;
1441
1482
  for (const listener of this.inputListeners) {
@@ -2110,6 +2151,7 @@ export class TuiBase extends Container {
2110
2151
  this.placementEpoch++;
2111
2152
  }
2112
2153
  renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2154
+ this.#scrollbackStale = false;
2113
2155
  let buffer = TUI.FRAME_BEGIN;
2114
2156
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2115
2157
  if (!this.shouldPreserveMuxScrollback()) {
@@ -2137,6 +2179,44 @@ export class TuiBase extends Container {
2137
2179
  this.previousHeight = height;
2138
2180
  this.placementEpoch++;
2139
2181
  }
2182
+ /**
2183
+ * The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
2184
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
2185
+ * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
2186
+ * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
2187
+ * involved, so the caller replays as before.
2188
+ */
2189
+ renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2190
+ const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
2191
+ const rowCount = Math.max(height, newLines.length - start);
2192
+ const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
2193
+ const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
2194
+ if (rows.some(isImageLine) || previousVisible.some(isImageLine))
2195
+ return false;
2196
+ let buffer = TUI.FRAME_BEGIN;
2197
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2198
+ if (currentScreenRow > 0)
2199
+ buffer += `\x1b[${currentScreenRow}A`;
2200
+ for (let row = 0; row < rows.length; row++) {
2201
+ if (row > 0)
2202
+ buffer += "\r\n";
2203
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
2204
+ buffer += rows[row];
2205
+ }
2206
+ const lastRow = start + rows.length - 1;
2207
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, lastRow);
2208
+ writeBounded(this.terminal, buffer);
2209
+ this.#scrollbackStale = true;
2210
+ this.cursorRow = Math.max(0, newLines.length - 1);
2211
+ this.maxLinesRendered = newLines.length;
2212
+ this.previousViewportTop = Math.max(0, lastRow + 1 - height);
2213
+ this.setPreviousLines(newLines, rawLines);
2214
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
2215
+ this.previousWidth = width;
2216
+ this.previousHeight = height;
2217
+ this.placementEpoch++;
2218
+ return true;
2219
+ }
2140
2220
  renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
2141
2221
  const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
2142
2222
  const nextVisible = this.getViewportRows(newLines, viewportTop, height);
@@ -2293,11 +2373,24 @@ export class TuiBase extends Container {
2293
2373
  const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
2294
2374
  newLines = normalizedLines.lines;
2295
2375
  const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
2376
+ if (this.#scrollbackCatchUpPending) {
2377
+ this.#scrollbackCatchUpPending = false;
2378
+ if (!preserveMuxScrollback && !widthChanged && !heightChanged) {
2379
+ this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2380
+ return;
2381
+ }
2382
+ // A resize frame takes its own path below; if that path does not rewrite the scrollback, the
2383
+ // rows are still stale and the next key press catches up.
2384
+ if (!preserveMuxScrollback)
2385
+ this.#scrollbackStale = true;
2386
+ }
2296
2387
  // Helper to clear scrollback and viewport and render all new lines
2297
2388
  const fullRender = (clear, clearScrollback = clear) => {
2298
2389
  this.fullRedrawCount += 1;
2299
2390
  let buffer = TUI.FRAME_BEGIN;
2300
2391
  if (clear) {
2392
+ if (clearScrollback)
2393
+ this.#scrollbackStale = false;
2301
2394
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2302
2395
  buffer += "\x1b[2J\x1b[H";
2303
2396
  if (clearScrollback && !preserveMuxScrollback && process.platform !== "win32") {
@@ -2514,6 +2607,10 @@ export class TuiBase extends Container {
2514
2607
  fullRender(true, false);
2515
2608
  }
2516
2609
  }
2610
+ else if (this.#holdScrollbackReplay &&
2611
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2612
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2613
+ }
2517
2614
  else {
2518
2615
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2519
2616
  }
@@ -2539,6 +2636,10 @@ export class TuiBase extends Container {
2539
2636
  fullRender(true, false);
2540
2637
  }
2541
2638
  }
2639
+ else if (this.#holdScrollbackReplay &&
2640
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2641
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2642
+ }
2542
2643
  else {
2543
2644
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2544
2645
  }
@@ -2793,6 +2894,18 @@ export class TuiBase extends Container {
2793
2894
  });
2794
2895
  }
2795
2896
  }
2897
+ /**
2898
+ * Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
2899
+ * replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
2900
+ * a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
2901
+ */
2902
+ function isTerminalReport(data) {
2903
+ return (data.startsWith("\x1b[<") ||
2904
+ data.startsWith("\x1b[M") ||
2905
+ (data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
2906
+ data.startsWith("\x1b[?") ||
2907
+ /^\x1b\[\d+(;\d+)*t$/.test(data));
2908
+ }
2796
2909
  /** Legacy main-screen renderer export. */
2797
2910
  export class TUI extends TuiBase {
2798
2911
  constructor() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.10-4",
3
+ "version": "2026.10.10-6",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",