@oh-my-pi/pi-tui 16.2.13 → 16.3.2

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/src/tui.ts CHANGED
@@ -21,6 +21,7 @@ import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
21
21
  import {
22
22
  encodeKittyDeleteImage,
23
23
  ImageProtocol,
24
+ isInsideTerminalMultiplexer,
24
25
  setCellDimensions,
25
26
  setTerminalImageProtocol,
26
27
  shouldEnableSynchronizedOutputByDefault,
@@ -387,17 +388,7 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
387
388
 
388
389
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
389
390
  function isMultiplexerSession(): boolean {
390
- // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
391
- // signals. TERM can also survive when those are stripped (`sudo` without -E,
392
- // `su`, env-sanitizing launchers/ssh), so keep the TERM prefix fallback aligned
393
- // with sibling multiplexer checks (terminal-capabilities.ts). Misclassifying a
394
- // multiplexer as a direct terminal lets resize/reset paths emit ED3 (`CSI 3 J`)
395
- // and wipe pane scrollback. Do not use CMUX_SOCKET_PATH here: it is a CLI socket
396
- // override and can be set outside a CMUX terminal.
397
- if (Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ) return true;
398
- if (Bun.env.CMUX_WORKSPACE_ID || Bun.env.CMUX_SURFACE_ID) return true;
399
- const term = Bun.env.TERM?.toLowerCase() ?? "";
400
- return term.startsWith("tmux") || term.startsWith("screen");
391
+ return isInsideTerminalMultiplexer();
401
392
  }
402
393
 
403
394
  /**
@@ -923,8 +914,23 @@ export class TUI extends Container {
923
914
  #renderTimer: RenderTimer | undefined;
924
915
  #renderScheduler: RenderScheduler;
925
916
  #lastRenderAt = 0;
917
+ /**
918
+ * Wall-clock cost of the most recent `#doRender()` call. Used by
919
+ * `#scheduleRender` to inflate the next render delay proportionally so a
920
+ * spike of slow frames (large transcript diffs, huge assistant text wrap,
921
+ * component-tree walks) does not busy-loop the CPU: the throttle would
922
+ * otherwise collapse to zero once `elapsed >= MIN_RENDER_INTERVAL_MS` and
923
+ * fire the next frame immediately (see #4145).
924
+ */
925
+ #lastFrameCostMs = 0;
926
926
  static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
927
927
  static readonly #INPUT_RENDER_GRACE_MS = TUI.#MIN_RENDER_INTERVAL_MS;
928
+ /**
929
+ * Cap on the adaptive floor derived from `#lastFrameCostMs`. Bounds the UI
930
+ * responsiveness at ~5 fps under sustained heavy renders — anything slower
931
+ * feels dead to the user and no longer justifies further CPU savings.
932
+ */
933
+ static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
928
934
  #inputRenderGraceUntilMs = 0;
929
935
  // Pane-reflow settle window for tmux/screen/zellij. The host process gets
930
936
  // SIGWINCH (and `process.stdout` already reports the new geometry) before
@@ -972,6 +978,8 @@ export class TUI extends Container {
972
978
  // ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
973
979
  // drift and would just see an unnecessary post-paint latency. See #2095.
974
980
  static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
981
+ static readonly #CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES = 512 * 1024;
982
+ static readonly #CONPTY_FRAME_RETAIN_BYTES = 64 * 1024;
975
983
  #postFullPaintSettleUntilMs = 0;
976
984
  #postFullPaintSettleTimer: RenderTimer | undefined;
977
985
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
@@ -1831,8 +1839,7 @@ export class TUI extends Container {
1831
1839
  this.#prepareForcedRender(!isMultiplexerSession());
1832
1840
  this.#resizeEventPending = true;
1833
1841
  this.#renderRequested = false;
1834
- this.#lastRenderAt = this.#renderScheduler.now();
1835
- this.#doRender();
1842
+ this.#executeRender();
1836
1843
  }
1837
1844
 
1838
1845
  requestRender(force = false, options?: RenderRequestOptions): void {
@@ -1863,8 +1870,7 @@ export class TUI extends Container {
1863
1870
  return;
1864
1871
  }
1865
1872
  this.#renderRequested = false;
1866
- this.#lastRenderAt = this.#renderScheduler.now();
1867
- this.#doRender();
1873
+ this.#executeRender();
1868
1874
  });
1869
1875
  return;
1870
1876
  }
@@ -2085,8 +2091,7 @@ export class TUI extends Container {
2085
2091
  this.#ghosttyInitialImageDelayTimer = undefined;
2086
2092
  this.#ghosttyInitialImageDelayDone = true;
2087
2093
  if (this.#stopped) return;
2088
- this.#lastRenderAt = this.#renderScheduler.now();
2089
- this.#doRender();
2094
+ this.#executeRender();
2090
2095
  if (this.#renderRequested) this.#scheduleRender();
2091
2096
  }, delayMs);
2092
2097
  return true;
@@ -2114,22 +2119,41 @@ export class TUI extends Container {
2114
2119
  const now = this.#renderScheduler.now();
2115
2120
  const elapsed = now - this.#lastRenderAt;
2116
2121
  const cadenceDelay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
2122
+ // Adaptive backpressure — target ~50% render duty cycle: the next frame
2123
+ // starts no sooner than `last_frame_end + last_frame_cost`, i.e.
2124
+ // `last_frame_start + 2 × last_frame_cost`. So `elapsed` (which counts
2125
+ // from the last frame's start) must already exceed twice the cost
2126
+ // before we allow the follow-up render to fire. Capped so a
2127
+ // pathological one-off spike doesn't lock the UI (#4145).
2128
+ const adaptiveFloor = Math.min(TUI.#MAX_ADAPTIVE_RENDER_MS, this.#lastFrameCostMs * 2);
2129
+ const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
2117
2130
  const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
2118
- const delay = Math.max(cadenceDelay, inputGraceDelay);
2131
+ const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
2119
2132
  this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
2120
2133
  this.#renderTimer = undefined;
2121
2134
  if (this.#stopped || !this.#renderRequested) {
2122
2135
  return;
2123
2136
  }
2124
2137
  this.#renderRequested = false;
2125
- this.#lastRenderAt = this.#renderScheduler.now();
2126
- this.#doRender();
2138
+ this.#executeRender();
2127
2139
  if (this.#renderRequested) {
2128
2140
  this.#scheduleRender();
2129
2141
  }
2130
2142
  }, delay);
2131
2143
  }
2132
2144
 
2145
+ /**
2146
+ * Wrap `#doRender()` so every path records the wall-clock frame cost that
2147
+ * feeds adaptive backpressure. Set `#lastRenderAt` first (some render code
2148
+ * reads it re-entrantly) and compute the cost once the paint returns.
2149
+ */
2150
+ #executeRender(): void {
2151
+ const start = this.#renderScheduler.now();
2152
+ this.#lastRenderAt = start;
2153
+ this.#doRender();
2154
+ this.#lastFrameCostMs = this.#renderScheduler.now() - start;
2155
+ }
2156
+
2133
2157
  #handleInput(data: string): void {
2134
2158
  // Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the
2135
2159
  // first key in a double-key gesture schedules an immediate slow repaint,
@@ -2458,6 +2482,56 @@ export class TUI extends Container {
2458
2482
  return markers;
2459
2483
  }
2460
2484
 
2485
+ #truncateLargeConptyFrame(
2486
+ lines: string[],
2487
+ width: number,
2488
+ height: number,
2489
+ cursorPos: { row: number; col: number } | null,
2490
+ ): { lines: string[]; cursorPos: { row: number; col: number } | null } {
2491
+ if (!isConPTYHosted()) return { lines, cursorPos };
2492
+
2493
+ let totalBytes = 0;
2494
+ let exceedsThreshold = false;
2495
+ for (const line of lines) {
2496
+ totalBytes += Buffer.byteLength(line, "utf8") + 8;
2497
+ if (totalBytes > TUI.#CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES) {
2498
+ exceedsThreshold = true;
2499
+ break;
2500
+ }
2501
+ }
2502
+ if (!exceedsThreshold) return { lines, cursorPos };
2503
+
2504
+ let retainedBytes = 0;
2505
+ let retainedStart = lines.length;
2506
+ while (
2507
+ retainedStart > 0 &&
2508
+ (retainedBytes < TUI.#CONPTY_FRAME_RETAIN_BYTES || lines.length - retainedStart < height)
2509
+ ) {
2510
+ retainedStart -= 1;
2511
+ retainedBytes += Buffer.byteLength(lines[retainedStart] ?? "", "utf8") + 8;
2512
+ }
2513
+ if (retainedStart <= 0) return { lines, cursorPos };
2514
+
2515
+ const marker = truncateToWidth(
2516
+ `[${retainedStart} older lines hidden to keep Windows console resume responsive]`,
2517
+ width,
2518
+ Ellipsis.Omit,
2519
+ );
2520
+ const truncated = new Array<string>(lines.length - retainedStart + 1);
2521
+ truncated[0] = marker;
2522
+ for (let i = retainedStart; i < lines.length; i++) {
2523
+ truncated[i - retainedStart + 1] = lines[i] ?? "";
2524
+ }
2525
+
2526
+ if (cursorPos === null || cursorPos.row < retainedStart) {
2527
+ return { lines: truncated, cursorPos: null };
2528
+ }
2529
+ return {
2530
+ lines: truncated,
2531
+ cursorPos: { row: cursorPos.row - retainedStart + 1, col: cursorPos.col },
2532
+ };
2533
+ }
2534
+
2461
2535
  #terminalLine(line: string): string {
2462
2536
  if (TERMINAL.isImageLine(line)) return line;
2463
2537
  const coalesced = coalesceAdjacentSgr(line);
@@ -2762,6 +2836,7 @@ export class TUI extends Container {
2762
2836
  }
2763
2837
  window = this.#prepareLinesArray(window, width);
2764
2838
  }
2839
+ const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
2765
2840
 
2766
2841
  const intent: RenderIntent = fullPaint
2767
2842
  ? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
@@ -2792,6 +2867,7 @@ export class TUI extends Container {
2792
2867
  clearScrollback: intent.clearScrollback,
2793
2868
  chunkTo,
2794
2869
  windowTop,
2870
+ cursorTrackingLineCount,
2795
2871
  });
2796
2872
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2797
2873
  this.#updateCommittedAuditRows(
@@ -2817,6 +2893,8 @@ export class TUI extends Container {
2817
2893
  prevWindowTop,
2818
2894
  prevHardwareCursorRow,
2819
2895
  forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
2896
+ repaintVirtualScrollInPlace: hasVisibleOverlay,
2897
+ cursorTrackingLineCount,
2820
2898
  });
2821
2899
  for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
2822
2900
  this.#committedPrefix.push(rawFrame[i] ?? "");
@@ -3198,10 +3276,48 @@ export class TUI extends Container {
3198
3276
  cursorPos: { row: number; col: number } | null,
3199
3277
  purgeSequence: string,
3200
3278
  imageTransmitBuffer: string,
3201
- options: { clearScrollback: boolean; chunkTo: number; windowTop: number },
3279
+ options: {
3280
+ clearScrollback: boolean;
3281
+ chunkTo: number;
3282
+ windowTop: number;
3283
+ cursorTrackingLineCount: number;
3284
+ },
3202
3285
  ): void {
3203
3286
  this.#fullRedrawCount += 1;
3204
- const { chunkTo, windowTop } = options;
3287
+ const { chunkTo, windowTop, cursorTrackingLineCount } = options;
3288
+ // Map the frame-space cursor into paint space: committed-prefix rows
3289
+ // keep their index, visible-window rows land after the prefix, and a
3290
+ // cursor in neither region (hidden behind the overlay gap) hides.
3291
+ let paintCursorPos: { row: number; col: number } | null = null;
3292
+ if (cursorPos !== null) {
3293
+ if (cursorPos.row < chunkTo) {
3294
+ paintCursorPos = cursorPos;
3295
+ } else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) {
3296
+ paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col };
3297
+ }
3298
+ }
3299
+ // ConPTY hosts bound the replay: merge prefix + window into one array
3300
+ // so #truncateLargeConptyFrame can measure the payload and retain only
3301
+ // the tail. Gated on the host check — everywhere else the merge would
3302
+ // copy a pointer per committed row (a 50k-row session = 50k-entry
3303
+ // array per resize step / theme change / session replace) just to be
3304
+ // returned unchanged. `paintLines` stays null unless truncation
3305
+ // actually rewrote the replay.
3306
+ let paintLines: string[] | null = null;
3307
+ let paintLineCount = chunkTo + height;
3308
+ if (isConPTYHosted()) {
3309
+ const merged = new Array<string>(chunkTo + height);
3310
+ for (let i = 0; i < chunkTo; i++) merged[i] = frame[i] ?? "";
3311
+ for (let screenRow = 0; screenRow < height; screenRow++) {
3312
+ merged[chunkTo + screenRow] = window[screenRow] ?? "";
3313
+ }
3314
+ const paint = this.#truncateLargeConptyFrame(merged, width, height, paintCursorPos);
3315
+ if (paint.lines !== merged) {
3316
+ paintLines = paint.lines;
3317
+ paintLineCount = paint.lines.length;
3318
+ paintCursorPos = paint.cursorPos;
3319
+ }
3320
+ }
3205
3321
  let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
3206
3322
  if (options.clearScrollback) {
3207
3323
  buffer += "\x1b[2J\x1b[H\x1b[3J";
@@ -3218,21 +3334,43 @@ export class TUI extends Container {
3218
3334
  // DECCARA fills optimize only the rows that stay visible; history-bound
3219
3335
  // rows are written as full styled strings (their background must
3220
3336
  // survive in scrollback, which DECCARA cannot reach).
3221
- const { texts, sequence } = this.#deccaraFillsEnabled()
3222
- ? planDeccaraFills(window, width)
3223
- : { texts: window, sequence: "" };
3224
- let wroteLine = false;
3225
- for (let i = 0; i < chunkTo; i++) {
3226
- if (wroteLine) buffer += "\r\n";
3227
- buffer += this.#terminalLine(frame[i] ?? "");
3228
- wroteLine = true;
3229
- }
3230
- for (let screenRow = 0; screenRow < height; screenRow++) {
3231
- if (wroteLine) buffer += "\r\n";
3232
- buffer += this.#terminalLine(texts[screenRow] ?? "");
3233
- wroteLine = true;
3337
+ const visibleStart = Math.max(0, paintLineCount - height);
3338
+ let fillSequence = "";
3339
+ let visibleTexts: string[] | null = null;
3340
+ if (this.#deccaraFillsEnabled() && visibleStart < paintLineCount) {
3341
+ // Untruncated, the visible slice is exactly the caller's window
3342
+ // (visibleStart === chunkTo) reuse it rather than copying;
3343
+ // planDeccaraFills fills its own `texts` and never mutates input.
3344
+ let visible = window;
3345
+ if (paintLines !== null) {
3346
+ visible = new Array<string>(paintLineCount - visibleStart);
3347
+ for (let k = 0; k < visible.length; k++) visible[k] = paintLines[visibleStart + k] ?? "";
3348
+ }
3349
+ const plan = planDeccaraFills(visible, width);
3350
+ visibleTexts = plan.texts;
3351
+ fillSequence = plan.sequence;
3352
+ }
3353
+ if (paintLines === null) {
3354
+ // Common path: emit straight from the source arrays (the
3355
+ // pre-merge two-loop form); byte-identical to replaying the
3356
+ // merged array.
3357
+ for (let i = 0; i < chunkTo; i++) {
3358
+ if (i > 0) buffer += "\r\n";
3359
+ buffer += this.#terminalLine(frame[i] ?? "");
3360
+ }
3361
+ for (let screenRow = 0; screenRow < height; screenRow++) {
3362
+ if (chunkTo + screenRow > 0) buffer += "\r\n";
3363
+ buffer += this.#terminalLine(visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? ""));
3364
+ }
3365
+ } else {
3366
+ for (let i = 0; i < paintLines.length; i++) {
3367
+ if (i > 0) buffer += "\r\n";
3368
+ buffer += this.#terminalLine(
3369
+ visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""),
3370
+ );
3371
+ }
3234
3372
  }
3235
- buffer += sequence;
3373
+ buffer += fillSequence;
3236
3374
  // Park the hardware cursor at real content bottom, not the padded
3237
3375
  // window bottom — a later height shrink would otherwise scroll live
3238
3376
  // rows into scrollback and duplicate them per resize step.
@@ -3240,14 +3378,30 @@ export class TUI extends Container {
3240
3378
  const parkUp = height - contentRows;
3241
3379
  if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
3242
3380
  const contentBottomRow = windowTop + contentRows - 1;
3243
- const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
3381
+ const paintContentBottomRow = Math.max(0, paintLineCount - 1 - parkUp);
3382
+ const cursorControl = this.#cursorControlSequence(paintCursorPos, paintLineCount, paintContentBottomRow);
3244
3383
  buffer += cursorControl.seq;
3245
3384
  buffer += this.#paintEndSequence;
3246
3385
  this.terminal.write(buffer);
3247
3386
 
3387
+ const committedCursorState = paintCursorPos
3388
+ ? this.#targetHardwareCursorState(cursorPos, cursorTrackingLineCount)
3389
+ : null;
3390
+ const committedCursor = committedCursorState
3391
+ ? {
3392
+ toRow: committedCursorState.row,
3393
+ state: committedCursorState,
3394
+ visible: committedCursorState.visible,
3395
+ }
3396
+ : {
3397
+ toRow: contentBottomRow,
3398
+ state: null,
3399
+ visible: cursorControl.visible,
3400
+ };
3401
+
3248
3402
  this.#committedRows = chunkTo;
3249
3403
  this.#windowTopRow = windowTop;
3250
- this.#commit(frame, window, width, height, cursorControl);
3404
+ this.#commit(frame, window, width, height, committedCursor);
3251
3405
  }
3252
3406
 
3253
3407
  /**
@@ -3277,8 +3431,7 @@ export class TUI extends Container {
3277
3431
  #requestResizeViewportPaint(): void {
3278
3432
  if (this.#stopped) return;
3279
3433
  this.#renderRequested = false;
3280
- this.#lastRenderAt = this.#renderScheduler.now();
3281
- this.#doRender();
3434
+ this.#executeRender();
3282
3435
  if (this.#renderRequested) this.#scheduleRender();
3283
3436
  }
3284
3437
 
@@ -3502,9 +3655,19 @@ export class TUI extends Container {
3502
3655
  prevWindowTop: number;
3503
3656
  prevHardwareCursorRow: number;
3504
3657
  forceWindowRewrite: boolean;
3658
+ repaintVirtualScrollInPlace: boolean;
3659
+ cursorTrackingLineCount: number;
3505
3660
  },
3506
3661
  ): void {
3507
- const { chunkTo, windowTop, prevWindowTop, prevHardwareCursorRow, forceWindowRewrite } = options;
3662
+ const {
3663
+ chunkTo,
3664
+ windowTop,
3665
+ prevWindowTop,
3666
+ prevHardwareCursorRow,
3667
+ forceWindowRewrite,
3668
+ repaintVirtualScrollInPlace,
3669
+ cursorTrackingLineCount,
3670
+ } = options;
3508
3671
  const chunkFrom = this.#committedRows;
3509
3672
  const chunkLength = chunkTo - chunkFrom;
3510
3673
  const scroll = windowTop - prevWindowTop;
@@ -3555,7 +3718,7 @@ export class TUI extends Container {
3555
3718
  }
3556
3719
  cursorFromRow = windowTop + lastChanged;
3557
3720
  }
3558
- const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
3721
+ const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
3559
3722
  buffer += cursorControl.seq;
3560
3723
  buffer += this.#paintEndSequence;
3561
3724
  this.terminal.write(buffer);
@@ -3566,12 +3729,17 @@ export class TUI extends Container {
3566
3729
  }
3567
3730
  }
3568
3731
 
3569
- // In-window diff: nothing scrolls, nothing commits.
3570
- if (chunkLength === 0 && scroll === 0) {
3571
- if (forceWindowRewrite) this.#fullRedrawCount += 1;
3572
- let firstChanged = forceWindowRewrite ? 0 : -1;
3573
- let lastChanged = forceWindowRewrite ? height - 1 : -1;
3574
- if (!forceWindowRewrite) {
3732
+ // In-window diff: nothing commits. While an overlay is visible, repaint
3733
+ // the full viewport in place from a top-clamped cursor origin. Overlay
3734
+ // cursor-only frames can leave the tracked row behind the physical cursor;
3735
+ // a relative partial rewrite from that stale origin can CRLF on the bottom
3736
+ // row and scroll native history without appending to the commit tape.
3737
+ const overlayInPlaceRewrite = repaintVirtualScrollInPlace;
3738
+ if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) {
3739
+ if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1;
3740
+ let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1;
3741
+ let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1;
3742
+ if (!forceWindowRewrite && !overlayInPlaceRewrite) {
3575
3743
  const comparable = previousWindow.length === height;
3576
3744
  for (let r = 0; r < height; r++) {
3577
3745
  if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
@@ -3581,15 +3749,22 @@ export class TUI extends Container {
3581
3749
  }
3582
3750
  if (firstChanged === -1) {
3583
3751
  if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
3584
- this.#writeCursorPosition(cursorPos, frame.length);
3752
+ this.#writeCursorPosition(cursorPos, cursorTrackingLineCount);
3585
3753
  this.#previousWidth = width;
3586
3754
  this.#previousHeight = height;
3587
3755
  return;
3588
3756
  }
3589
3757
  let buffer = this.#paintBeginSequence + purgeSequence;
3590
- const rowDelta = firstChanged - currentScreenRow;
3591
- if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
3592
- else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
3758
+ if (overlayInPlaceRewrite) {
3759
+ // The cursor tracker can be stale after overlay-only frames. A large
3760
+ // CUU clamps at the viewport top without using absolute cursor home,
3761
+ // so the following full-window rewrite cannot overflow the bottom.
3762
+ if (height > 1) buffer += `\x1b[${height - 1}A`;
3763
+ } else {
3764
+ const rowDelta = firstChanged - currentScreenRow;
3765
+ if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
3766
+ else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
3767
+ }
3593
3768
  buffer += "\r";
3594
3769
  // DECCARA-optimize the contiguous rewritten range (visible rows
3595
3770
  // only; rectangles are absolute screen rows).
@@ -3615,10 +3790,11 @@ export class TUI extends Container {
3615
3790
  buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
3616
3791
  cursorFromRow = contentBottomRow;
3617
3792
  }
3618
- const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
3793
+ const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
3619
3794
  buffer += cursorControl.seq;
3620
3795
  buffer += this.#paintEndSequence;
3621
3796
  this.terminal.write(buffer);
3797
+ this.#windowTopRow = windowTop;
3622
3798
  this.#commit(frame, window, width, height, cursorControl);
3623
3799
  return;
3624
3800
  }
@@ -3644,7 +3820,7 @@ export class TUI extends Container {
3644
3820
  }
3645
3821
  const parkUp = height - 1 - (contentBottomRow - windowTop);
3646
3822
  if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
3647
- const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
3823
+ const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, contentBottomRow);
3648
3824
  buffer += cursorControl.seq;
3649
3825
  buffer += this.#paintEndSequence;
3650
3826
  this.terminal.write(buffer);
package/src/utils.ts CHANGED
@@ -143,12 +143,13 @@ export function extractSegments(
143
143
 
144
144
  // Pre-allocated space buffer for padding
145
145
  const SPACE_BUFFER = " ".repeat(512);
146
+ const TAB_SPACES = " ".repeat(DEFAULT_TAB_WIDTH);
146
147
 
147
148
  /*
148
149
  * Replace tabs with the fixed display tab width for consistent rendering.
149
150
  */
150
151
  export function replaceTabs(text: string): string {
151
- return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH));
152
+ return text.replaceAll("\t", TAB_SPACES);
152
153
  }
153
154
 
154
155
  /**