@code-yeongyu/senpi-tui 2026.10.10 → 2026.10.11

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
@@ -624,9 +624,15 @@ export function isViewportTUI(tui) {
624
624
  return tui[VIEWPORT_TUI] === true;
625
625
  }
626
626
  export class TuiBase extends Container {
627
+ /** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
628
+ #muxViewportRepaintPending;
627
629
  /** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
628
630
  #minRenderIntervalMs;
629
631
  #lastCursorVisibility;
632
+ #holdScrollbackReplay;
633
+ #releaseHoldOnInput;
634
+ #scrollbackStale;
635
+ #scrollbackCatchUpPending;
630
636
  get hasOverlayEntries() {
631
637
  return this.overlayStack.length > 0;
632
638
  }
@@ -637,6 +643,8 @@ export class TuiBase extends Container {
637
643
  this.previousRawLines = [];
638
644
  this.normalizeMemo = new Map();
639
645
  this.previousKittyImageIds = new Set();
646
+ /** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
647
+ this.#muxViewportRepaintPending = false;
640
648
  this.previousWidth = 0;
641
649
  this.previousHeight = 0;
642
650
  this.focusedComponent = null;
@@ -663,6 +671,10 @@ export class TuiBase extends Container {
663
671
  this.pendingTerminalColorQueries = [];
664
672
  this.terminalColorSchemeListeners = new Set();
665
673
  this.terminalColorSchemeNotificationsEnabled = false;
674
+ this.#holdScrollbackReplay = false;
675
+ this.#releaseHoldOnInput = false;
676
+ this.#scrollbackStale = false;
677
+ this.#scrollbackCatchUpPending = false;
666
678
  // Overlay stack for modal components rendered on top of base content
667
679
  this.focusOrderCounter = 0;
668
680
  this.overlayStack = [];
@@ -1260,6 +1272,11 @@ export class TuiBase extends Container {
1260
1272
  }
1261
1273
  stop(options = {}) {
1262
1274
  this.stopped = true;
1275
+ // The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
1276
+ // for an external editor or a suspend in the middle of a turn must not drop it.
1277
+ this.#scrollbackStale = false;
1278
+ this.#scrollbackCatchUpPending = false;
1279
+ this.#muxViewportRepaintPending = false;
1263
1280
  this.renderRequested = false;
1264
1281
  this.inputRenderPending = false;
1265
1282
  this.cancelRenderTimer();
@@ -1303,6 +1320,25 @@ export class TuiBase extends Container {
1303
1320
  this.maxLinesRendered = 0;
1304
1321
  this.previousViewportTop = 0;
1305
1322
  }
1323
+ /**
1324
+ * While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
1325
+ * (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
1326
+ * on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
1327
+ * report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
1328
+ * surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
1329
+ */
1330
+ setScrollbackReplayHold(hold) {
1331
+ this.#holdScrollbackReplay = hold !== false;
1332
+ this.#releaseHoldOnInput = hold === "until-input";
1333
+ }
1334
+ /** Replays the scrollback once if a held frame left rows above the viewport stale. */
1335
+ catchUpScrollback() {
1336
+ if (!this.#scrollbackStale)
1337
+ return;
1338
+ this.#scrollbackStale = false;
1339
+ this.#scrollbackCatchUpPending = true;
1340
+ this.requestRender();
1341
+ }
1306
1342
  renderNow(force = false) {
1307
1343
  if (force)
1308
1344
  this.resetForcedRenderState();
@@ -1366,6 +1402,7 @@ export class TuiBase extends Container {
1366
1402
  }
1367
1403
  /** Drop every cached frame so the next render repaints from a clean slate. */
1368
1404
  resetForcedRenderState() {
1405
+ this.#scrollbackCatchUpPending = false;
1369
1406
  this.resetRenderState();
1370
1407
  this.dropPreviousLines();
1371
1408
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -1422,9 +1459,22 @@ export class TuiBase extends Container {
1422
1459
  if (this.mode !== "fullscreen") {
1423
1460
  const focus = consumeTmuxFocusEvent(data);
1424
1461
  if (focus.event !== null) {
1425
- resetCapabilitiesCache();
1426
- this.invalidate();
1427
- this.requestRender(true);
1462
+ if (this.shouldPreserveMuxScrollback()) {
1463
+ // senpi#1704: a forced render re-emitted the whole transcript into the pane's history on every
1464
+ // focus event. A pane losing focus is not visible, so it repaints nothing; a pane gaining focus
1465
+ // refreshes the terminal capabilities and repaints its viewport only.
1466
+ if (focus.event === "in") {
1467
+ resetCapabilitiesCache();
1468
+ this.invalidate();
1469
+ this.#muxViewportRepaintPending = true;
1470
+ this.requestRender();
1471
+ }
1472
+ }
1473
+ else {
1474
+ resetCapabilitiesCache();
1475
+ this.invalidate();
1476
+ this.requestRender(true);
1477
+ }
1428
1478
  if (focus.data.length === 0)
1429
1479
  return;
1430
1480
  data = focus.data;
@@ -1436,6 +1486,15 @@ export class TuiBase extends Container {
1436
1486
  if (this.consumeTerminalColorSchemeReport(data)) {
1437
1487
  return;
1438
1488
  }
1489
+ // A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
1490
+ // DEC private reports, window and cell-size reports) are not the user and must not trigger it.
1491
+ if (!isTerminalReport(data)) {
1492
+ if (this.#releaseHoldOnInput) {
1493
+ this.#releaseHoldOnInput = false;
1494
+ this.#holdScrollbackReplay = false;
1495
+ }
1496
+ this.catchUpScrollback();
1497
+ }
1439
1498
  if (this.inputListeners.size > 0) {
1440
1499
  let current = data;
1441
1500
  for (const listener of this.inputListeners) {
@@ -2110,6 +2169,7 @@ export class TuiBase extends Container {
2110
2169
  this.placementEpoch++;
2111
2170
  }
2112
2171
  renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2172
+ this.#scrollbackStale = false;
2113
2173
  let buffer = TUI.FRAME_BEGIN;
2114
2174
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2115
2175
  if (!this.shouldPreserveMuxScrollback()) {
@@ -2137,16 +2197,59 @@ export class TuiBase extends Container {
2137
2197
  this.previousHeight = height;
2138
2198
  this.placementEpoch++;
2139
2199
  }
2140
- renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
2200
+ /**
2201
+ * The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
2202
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
2203
+ * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
2204
+ * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
2205
+ * involved, so the caller replays as before.
2206
+ */
2207
+ renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2208
+ const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
2209
+ const rowCount = Math.max(height, newLines.length - start);
2210
+ const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
2211
+ const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
2212
+ if (rows.some(isImageLine) || previousVisible.some(isImageLine))
2213
+ return false;
2214
+ let buffer = TUI.FRAME_BEGIN;
2215
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2216
+ if (currentScreenRow > 0)
2217
+ buffer += `\x1b[${currentScreenRow}A`;
2218
+ for (let row = 0; row < rows.length; row++) {
2219
+ if (row > 0)
2220
+ buffer += "\r\n";
2221
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
2222
+ buffer += rows[row];
2223
+ }
2224
+ const lastRow = start + rows.length - 1;
2225
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, lastRow);
2226
+ writeBounded(this.terminal, buffer);
2227
+ this.#scrollbackStale = true;
2228
+ this.cursorRow = Math.max(0, newLines.length - 1);
2229
+ this.maxLinesRendered = newLines.length;
2230
+ this.previousViewportTop = Math.max(0, lastRow + 1 - height);
2231
+ this.setPreviousLines(newLines, rawLines);
2232
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
2233
+ this.previousWidth = width;
2234
+ this.previousHeight = height;
2235
+ this.placementEpoch++;
2236
+ return true;
2237
+ }
2238
+ renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height), home = "relative") {
2141
2239
  const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
2142
2240
  const nextVisible = this.getViewportRows(newLines, viewportTop, height);
2143
2241
  if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
2144
2242
  return false;
2145
2243
  }
2146
2244
  let buffer = TUI.FRAME_BEGIN;
2147
- const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
2148
- if (currentScreenRow > 0) {
2149
- buffer += `\x1b[${currentScreenRow}A`;
2245
+ if (home === "absolute") {
2246
+ buffer += "\x1b[H";
2247
+ }
2248
+ else {
2249
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
2250
+ if (currentScreenRow > 0) {
2251
+ buffer += `\x1b[${currentScreenRow}A`;
2252
+ }
2150
2253
  }
2151
2254
  for (let row = 0; row < height; row++) {
2152
2255
  if (row > 0)
@@ -2293,11 +2396,24 @@ export class TuiBase extends Container {
2293
2396
  const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
2294
2397
  newLines = normalizedLines.lines;
2295
2398
  const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
2399
+ if (this.#scrollbackCatchUpPending) {
2400
+ this.#scrollbackCatchUpPending = false;
2401
+ if (!preserveMuxScrollback && !widthChanged && !heightChanged) {
2402
+ this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2403
+ return;
2404
+ }
2405
+ // A resize frame takes its own path below; if that path does not rewrite the scrollback, the
2406
+ // rows are still stale and the next key press catches up.
2407
+ if (!preserveMuxScrollback)
2408
+ this.#scrollbackStale = true;
2409
+ }
2296
2410
  // Helper to clear scrollback and viewport and render all new lines
2297
2411
  const fullRender = (clear, clearScrollback = clear) => {
2298
2412
  this.fullRedrawCount += 1;
2299
2413
  let buffer = TUI.FRAME_BEGIN;
2300
2414
  if (clear) {
2415
+ if (clearScrollback)
2416
+ this.#scrollbackStale = false;
2301
2417
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2302
2418
  buffer += "\x1b[2J\x1b[H";
2303
2419
  if (clearScrollback && !preserveMuxScrollback && process.platform !== "win32") {
@@ -2362,11 +2478,37 @@ export class TuiBase extends Container {
2362
2478
  }
2363
2479
  // Width changes always need a full re-render because wrapping changes.
2364
2480
  if (widthChanged) {
2481
+ // A width change (or a forced render) repaints everything below; a focus repaint queued in the
2482
+ // same tick is satisfied by it.
2483
+ this.#muxViewportRepaintPending = false;
2365
2484
  logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
2366
- // In multiplexers, re-emit the viewport without 3J so pane history survives; an older copy may remain above.
2485
+ // In a multiplexer only the re-wrapped viewport is repainted: re-emitting every line of the buffer scrolled a
2486
+ // copy of the whole transcript into the pane's history on each width change (senpi#1704). Rows already in
2487
+ // that history keep their old wrapping. The pane re-wrapped the screen itself, so the repaint homes there.
2488
+ if (preserveMuxScrollback && this.previousWidth > 0) {
2489
+ if (this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, undefined, "absolute"))
2490
+ return;
2491
+ }
2367
2492
  fullRender(true, !preserveMuxScrollback);
2368
2493
  return;
2369
2494
  }
2495
+ if (this.#muxViewportRepaintPending) {
2496
+ this.#muxViewportRepaintPending = false;
2497
+ if (preserveMuxScrollback && !heightChanged) {
2498
+ logRedraw("multiplexer pane focus regained");
2499
+ // A frame queued before the focus event may have grown the content: follow it exactly as an
2500
+ // ordinary frame does, so rows added below (the editor, the status line) stay on screen.
2501
+ // After a shrink the viewport keeps its top (as the deleted-lines path does), so the repaint does
2502
+ // not scroll rows already in the pane's history into view a second time.
2503
+ const focusViewportTop = newLines.length > prevViewportTop
2504
+ ? Math.max(prevViewportTop, newLines.length - height)
2505
+ : Math.max(0, newLines.length - height);
2506
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, focusViewportTop)) {
2507
+ fullRender(true, false);
2508
+ }
2509
+ return;
2510
+ }
2511
+ }
2370
2512
  // Height changes normally need a full re-render to keep the visible viewport aligned,
2371
2513
  // but Termux changes height when the software keyboard shows or hides.
2372
2514
  // In that environment, a full redraw causes the entire history to replay on every toggle.
@@ -2514,6 +2656,10 @@ export class TuiBase extends Container {
2514
2656
  fullRender(true, false);
2515
2657
  }
2516
2658
  }
2659
+ else if (this.#holdScrollbackReplay &&
2660
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2661
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2662
+ }
2517
2663
  else {
2518
2664
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2519
2665
  }
@@ -2539,6 +2685,10 @@ export class TuiBase extends Container {
2539
2685
  fullRender(true, false);
2540
2686
  }
2541
2687
  }
2688
+ else if (this.#holdScrollbackReplay &&
2689
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2690
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2691
+ }
2542
2692
  else {
2543
2693
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2544
2694
  }
@@ -2793,6 +2943,18 @@ export class TuiBase extends Container {
2793
2943
  });
2794
2944
  }
2795
2945
  }
2946
+ /**
2947
+ * Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
2948
+ * replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
2949
+ * a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
2950
+ */
2951
+ function isTerminalReport(data) {
2952
+ return (data.startsWith("\x1b[<") ||
2953
+ data.startsWith("\x1b[M") ||
2954
+ (data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
2955
+ data.startsWith("\x1b[?") ||
2956
+ /^\x1b\[\d+(;\d+)*t$/.test(data));
2957
+ }
2796
2958
  /** Legacy main-screen renderer export. */
2797
2959
  export class TUI extends TuiBase {
2798
2960
  constructor() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.11",
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",