@gajae-code/tui 0.8.2 → 0.9.0

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
@@ -4,7 +4,7 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
- import { $flag, getDebugLogPath, logger } from "@gajae-code/utils";
7
+ import { $flag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
8
8
  import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
@@ -17,8 +17,10 @@ import {
17
17
  normalizeTerminalOutput,
18
18
  sliceByColumn,
19
19
  sliceWithWidth,
20
+ truncateLinesToWidth,
20
21
  truncateToWidth,
21
22
  visibleWidth,
23
+ visibleWidths,
22
24
  } from "./utils";
23
25
 
24
26
  const SEGMENT_RESET = "\x1b[0m";
@@ -152,6 +154,14 @@ function termLooksMultiplexed(value: string | undefined): boolean {
152
154
  return term.startsWith("tmux") || term.startsWith("screen");
153
155
  }
154
156
 
157
+ function isWindowsTerminalSession(): boolean {
158
+ return envIsEnabled(Bun.env.WT_SESSION) || Bun.env.TERM_PROGRAM === "Windows_Terminal";
159
+ }
160
+
161
+ function isViewportRepaintSession(): boolean {
162
+ return isMultiplexerSession() || isWindowsTerminalSession();
163
+ }
164
+
155
165
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
156
166
  function isMultiplexerSession(): boolean {
157
167
  return Boolean(
@@ -168,6 +178,10 @@ function useLegacyMultiplexerFullRender(): boolean {
168
178
  return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
169
179
  }
170
180
 
181
+ function useViewportRepaintPath(): boolean {
182
+ return isViewportRepaintSession() && !(isMultiplexerSession() && useLegacyMultiplexerFullRender());
183
+ }
184
+
171
185
  /**
172
186
  * Options for overlay positioning and sizing.
173
187
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -323,6 +337,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
323
337
  type LineNormalizationCacheEntry = {
324
338
  normalized: string;
325
339
  terminated: string;
340
+ width: number | undefined;
341
+ };
342
+
343
+ type TuiRenderCounterSnapshot = {
344
+ debugRedrawEnvReads: number;
345
+ debugRedrawAppendWrites: number;
346
+ differentialGuardVisibleWidthCalls: number;
326
347
  };
327
348
 
328
349
  /**
@@ -339,6 +360,7 @@ export class TUI extends Container {
339
360
  */
340
361
  #previousRaw: string[] = [];
341
362
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
363
+ #lineEmitWidthCache = new Map<string, number>();
342
364
  #lineTruncationCache = new Map<string, string>();
343
365
  #lineNormalizationCacheLimit = 0;
344
366
  #lineTruncationCacheLimit = 0;
@@ -369,20 +391,58 @@ export class TUI extends Container {
369
391
  #sixelProbeTimeout?: NodeJS.Timeout;
370
392
  #sixelProbeUnsubscribe?: () => void;
371
393
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
394
+ #debugRedraw = TUI.#readDebugRedrawFlag();
372
395
  // macOS: steady-block cursor anchors CJK IME overlays; disable with GJC_TUI_IME_CURSOR=0.
373
396
  readonly #useImeBlockCursor = $flag("GJC_TUI_IME_CURSOR", process.platform === "darwin");
374
397
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
375
398
  #imeCursorActive = false;
376
399
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
377
- // Opt-in: reuse the previous normalized off-screen prefix and only normalize/diff the
378
- // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical.
379
- #virtualViewport = $flag("PI_TUI_VIRTUAL_VIEWPORT");
400
+ // Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
401
+ // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
402
+ // set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
403
+ #virtualViewport = $flag("PI_TUI_VIRTUAL_VIEWPORT", true);
380
404
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
381
405
  #fullRedrawCount = 0;
382
406
  #stopped = false;
383
407
  #terminalUnavailable = false;
384
408
  #bottomPinnedComponent: Component | null = null;
385
409
 
410
+ #unsubscribeTabWidthChange?: () => void;
411
+ static #renderCounters: TuiRenderCounterSnapshot = {
412
+ debugRedrawEnvReads: 0,
413
+ debugRedrawAppendWrites: 0,
414
+ differentialGuardVisibleWidthCalls: 0,
415
+ };
416
+
417
+ static resetRenderCountersForTest(): void {
418
+ TUI.#renderCounters = {
419
+ debugRedrawEnvReads: 0,
420
+ debugRedrawAppendWrites: 0,
421
+ differentialGuardVisibleWidthCalls: 0,
422
+ };
423
+ }
424
+
425
+ static getRenderCountersForTest(): TuiRenderCounterSnapshot {
426
+ return { ...TUI.#renderCounters };
427
+ }
428
+
429
+ static #readDebugRedrawFlag(): boolean {
430
+ TUI.#renderCounters.debugRedrawEnvReads += 1;
431
+ return $flag("PI_DEBUG_REDRAW");
432
+ }
433
+
434
+ #appendDebugRedrawLog(message: string): void {
435
+ TUI.#renderCounters.debugRedrawAppendWrites += 1;
436
+ fs.appendFileSync(getDebugLogPath(), message);
437
+ }
438
+
439
+ #visibleWidthForDifferentialGuard(line: string): number {
440
+ const cached = this.#lineEmitWidthCache.get(line);
441
+ if (cached !== undefined) return cached;
442
+ TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
443
+ return visibleWidth(line);
444
+ }
445
+
386
446
  // Overlay stack for modal components rendered on top of base content
387
447
  overlayStack: {
388
448
  component: Component;
@@ -398,6 +458,18 @@ export class TUI extends Container {
398
458
  this.#showHardwareCursor = showHardwareCursor;
399
459
  }
400
460
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
461
+ this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
462
+ this.#lineTruncationCache.clear();
463
+ this.#lineNormalizationCache.clear();
464
+ this.#lineEmitWidthCache.clear();
465
+ this.requestRender(true, "tab-width-change");
466
+ });
467
+ }
468
+
469
+ override dispose(): void {
470
+ this.#unsubscribeTabWidthChange?.();
471
+ this.#unsubscribeTabWidthChange = undefined;
472
+ super.dispose();
401
473
  }
402
474
 
403
475
  get fullRedraws(): number {
@@ -829,24 +901,26 @@ export class TUI extends Container {
829
901
  this.#previousRaw = [];
830
902
  this.#lineNormalizationCache.clear();
831
903
  this.#lineTruncationCache.clear();
904
+ this.#lineEmitWidthCache.clear();
832
905
  this.#previousWidth = 0;
833
906
  this.#previousHeight = 0;
834
907
  }
835
908
 
836
909
  /**
837
- * Multiplexer-aware resize render request.
910
+ * Viewport-repaint-aware resize render request.
838
911
  *
839
912
  * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
840
913
  * to -1, which makes `#doRender` treat the frame as a width change and fall into the
841
914
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
842
915
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
843
916
  * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
844
- * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
845
- * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
846
- * Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
917
+ * high speed" resize storm. Windows Terminal can also visibly jump to the
918
+ * transcript top during streaming redraws, so viewport-repaint sessions keep
919
+ * force off and let `#doRender` repaint only the live viewport. Set
920
+ * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
847
921
  */
848
922
  requestResizeRender(): void {
849
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
923
+ this.requestRender(!useViewportRepaintPath() && !isTermuxSession(), "resize");
850
924
  }
851
925
 
852
926
  requestRender(force = false, source = "unknown"): void {
@@ -856,20 +930,24 @@ export class TUI extends Container {
856
930
  }
857
931
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
858
932
  if (force) {
933
+ const preserveViewportCursor = useViewportRepaintPath();
859
934
  // A forced full redraw supersedes any queued input-priority render.
860
935
  this.#inputRenderPending = false;
861
936
  this.#previousLines = [];
862
937
  this.#previousRaw = [];
863
938
  this.#lineNormalizationCache.clear();
864
939
  this.#lineTruncationCache.clear();
940
+ this.#lineEmitWidthCache.clear();
865
941
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
866
942
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
867
943
  this.#lineNormalizationCacheLimit = 0;
868
944
  this.#lineTruncationCacheLimit = 0;
869
- this.#cursorRow = 0;
870
- this.#hardwareCursorRow = 0;
871
- this.#viewportTopRow = 0;
872
- this.#maxLinesRendered = 0;
945
+ if (!preserveViewportCursor) {
946
+ this.#cursorRow = 0;
947
+ this.#hardwareCursorRow = 0;
948
+ this.#viewportTopRow = 0;
949
+ this.#maxLinesRendered = 0;
950
+ }
873
951
  if (this.#renderTimer) {
874
952
  clearTimeout(this.#renderTimer);
875
953
  this.#renderTimer = undefined;
@@ -1335,24 +1413,9 @@ export class TUI extends Container {
1335
1413
  if (cached !== undefined) return cached;
1336
1414
  const normalized = normalizeTerminalOutput(line);
1337
1415
  const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1338
- this.#lineNormalizationCache.set(line, { normalized, terminated });
1339
- return { normalized, terminated };
1340
- }
1341
-
1342
- #lineFitsWidth(normalizedLine: string, width: number): boolean {
1343
- return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
1344
- ? true
1345
- : visibleWidth(normalizedLine) <= width;
1346
- }
1347
-
1348
- #truncateNormalizedLine(normalizedLine: string, width: number): string {
1349
- const key = `${width}\0${normalizedLine}`;
1350
- const cached = this.#lineTruncationCache.get(key);
1351
- if (cached !== undefined) return cached;
1352
- const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
1353
- const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1354
- this.#lineTruncationCache.set(key, terminated);
1355
- return terminated;
1416
+ const entry = { normalized, terminated, width: undefined };
1417
+ this.#lineNormalizationCache.set(line, entry);
1418
+ return entry;
1356
1419
  }
1357
1420
 
1358
1421
  #trimLineCachesForRender(lineCount: number): void {
@@ -1362,6 +1425,8 @@ export class TUI extends Container {
1362
1425
  while (this.#lineNormalizationCache.size > limit) {
1363
1426
  const key = this.#lineNormalizationCache.keys().next().value;
1364
1427
  if (key === undefined) break;
1428
+ const entry = this.#lineNormalizationCache.get(key);
1429
+ if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
1365
1430
  this.#lineNormalizationCache.delete(key);
1366
1431
  }
1367
1432
  while (this.#lineTruncationCache.size > limit) {
@@ -1369,6 +1434,11 @@ export class TUI extends Container {
1369
1434
  if (key === undefined) break;
1370
1435
  this.#lineTruncationCache.delete(key);
1371
1436
  }
1437
+ while (this.#lineEmitWidthCache.size > limit * 2) {
1438
+ const key = this.#lineEmitWidthCache.keys().next().value;
1439
+ if (key === undefined) break;
1440
+ this.#lineEmitWidthCache.delete(key);
1441
+ }
1372
1442
  }
1373
1443
 
1374
1444
  getLineRenderCacheStats(): {
@@ -1385,17 +1455,66 @@ export class TUI extends Container {
1385
1455
  };
1386
1456
  }
1387
1457
 
1388
- /** Normalize + width-fit a single line for emission (image lines pass through). */
1389
- #normalizeLineForEmit(line: string, width: number): string {
1390
- if (TERMINAL.isImageLine(line)) return line;
1391
- const { normalized, terminated } = this.#normalizeLineForRender(line);
1392
- return this.#lineFitsWidth(normalized, width) ? terminated : this.#truncateNormalizedLine(normalized, width);
1458
+ #normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
1459
+ const widthCheckIndexes: number[] = [];
1460
+ const widthCheckLines: string[] = [];
1461
+ for (let i = start; i < lines.length; i++) {
1462
+ const line = lines[i];
1463
+ if (TERMINAL.isImageLine(line)) continue;
1464
+ const entry = this.#normalizeLineForRender(line);
1465
+ const { normalized, terminated } = entry;
1466
+ if (isPrintableAscii(normalized) && normalized.length <= width) {
1467
+ entry.width = normalized.length;
1468
+ this.#lineEmitWidthCache.set(terminated, normalized.length);
1469
+ lines[i] = terminated;
1470
+ continue;
1471
+ }
1472
+ widthCheckIndexes.push(i);
1473
+ widthCheckLines.push(normalized);
1474
+ }
1475
+
1476
+ const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
1477
+ const truncateIndexes: number[] = [];
1478
+ const truncateLines: string[] = [];
1479
+ for (let i = 0; i < widthCheckIndexes.length; i++) {
1480
+ const lineIndex = widthCheckIndexes[i];
1481
+ const normalized = widthCheckLines[i];
1482
+ const measuredWidth = widths[i] ?? 0;
1483
+ if (measuredWidth <= width) {
1484
+ const entry = this.#normalizeLineForRender(lines[lineIndex]);
1485
+ entry.width = measuredWidth;
1486
+ this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
1487
+ lines[lineIndex] = entry.terminated;
1488
+ continue;
1489
+ }
1490
+
1491
+ const key = `${width}\0${normalized}`;
1492
+ const cached = this.#lineTruncationCache.get(key);
1493
+ if (cached !== undefined) {
1494
+ this.#lineEmitWidthCache.set(cached, width);
1495
+ lines[lineIndex] = cached;
1496
+ continue;
1497
+ }
1498
+ truncateIndexes.push(lineIndex);
1499
+ truncateLines.push(normalized);
1500
+ }
1501
+
1502
+ const truncated = truncateLines.length === 0 ? [] : truncateLinesToWidth(truncateLines, width, Ellipsis.Omit);
1503
+ for (let i = 0; i < truncateIndexes.length; i++) {
1504
+ const lineIndex = truncateIndexes[i];
1505
+ const normalized = truncateLines[i];
1506
+ const truncatedLine = truncated[i] ?? "";
1507
+ const terminated = truncatedLine + (truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1508
+ this.#lineTruncationCache.set(`${width}\0${normalized}`, terminated);
1509
+ this.#lineEmitWidthCache.set(terminated, width);
1510
+ lines[lineIndex] = terminated;
1511
+ }
1512
+
1513
+ return lines;
1393
1514
  }
1394
1515
 
1395
1516
  #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1396
- for (let i = 0; i < lines.length; i++) {
1397
- lines[i] = this.#normalizeLineForEmit(lines[i], width);
1398
- }
1517
+ this.#normalizeLinesForEmit(lines, width);
1399
1518
  this.#trimLineCachesForRender(lines.length);
1400
1519
  return lines;
1401
1520
  }
@@ -1449,7 +1568,7 @@ export class TUI extends Container {
1449
1568
  if (lineIndex >= lines.length) continue;
1450
1569
  const line = lines[lineIndex];
1451
1570
  const isImage = TERMINAL.isImageLine(line);
1452
- if (!isImage && visibleWidth(line) > width) {
1571
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1453
1572
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1454
1573
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1455
1574
  buffer += truncatedLine;
@@ -1471,10 +1590,9 @@ export class TUI extends Container {
1471
1590
  buffer += "\x1b[?2026l";
1472
1591
  if (!this.#writeTerminal(buffer)) return false;
1473
1592
 
1474
- if ($flag("PI_DEBUG_REDRAW")) {
1475
- const logPath = getDebugLogPath();
1593
+ if (this.#debugRedraw) {
1476
1594
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1477
- fs.appendFileSync(logPath, msg);
1595
+ this.#appendDebugRedrawLog(msg);
1478
1596
  }
1479
1597
 
1480
1598
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1551,8 +1669,9 @@ export class TUI extends Container {
1551
1669
  if (stable) {
1552
1670
  const windowed = this.#previousLines.slice(0, winTop);
1553
1671
  for (let i = winTop; i < total; i++) {
1554
- windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
1672
+ windowed.push(rawLines[i]);
1555
1673
  }
1674
+ this.#normalizeLinesForEmit(windowed, width, winTop);
1556
1675
  this.#trimLineCachesForRender(total);
1557
1676
  newLines = windowed;
1558
1677
  diffStart = winTop;
@@ -1626,7 +1745,7 @@ export class TUI extends Container {
1626
1745
  this.#previousHeight = height;
1627
1746
  };
1628
1747
 
1629
- const multiplexerViewportRepaint = (reason: string): void => {
1748
+ const viewportRepaint = (reason: string): void => {
1630
1749
  this.#fullRedrawCount += 1;
1631
1750
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1632
1751
  const nextViewportTop = Math.max(0, newLines.length - height);
@@ -1643,7 +1762,7 @@ export class TUI extends Container {
1643
1762
  if (lineIndex >= newLines.length) continue;
1644
1763
  const line = newLines[lineIndex];
1645
1764
  const isImage = TERMINAL.isImageLine(line);
1646
- if (!isImage && visibleWidth(line) > width) {
1765
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1647
1766
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1648
1767
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1649
1768
  buffer += truncatedLine;
@@ -1665,15 +1784,14 @@ export class TUI extends Container {
1665
1784
  buffer += "\x1b[?2026l";
1666
1785
  if (!this.#writeTerminal(buffer)) return;
1667
1786
 
1668
- if ($flag("PI_DEBUG_REDRAW")) {
1669
- const logPath = getDebugLogPath();
1670
- const msg = `[${new Date().toISOString()}] multiplexerViewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1671
- fs.appendFileSync(logPath, msg);
1787
+ if (this.#debugRedraw) {
1788
+ const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1789
+ this.#appendDebugRedrawLog(msg);
1672
1790
  }
1673
- // In multiplexers this deliberately prioritizes the live viewport over
1791
+ // Viewport repaint deliberately prioritizes the live viewport over
1674
1792
  // historical scrollback repair. After offscreen changes, #previousLines
1675
1793
  // tracks the desired logical transcript, not every byte emitted into the
1676
- // multiplexer scrollback.
1794
+ // terminal scrollback.
1677
1795
  this.#cursorRow = Math.max(0, newLines.length - 1);
1678
1796
  this.#maxLinesRendered = newLines.length;
1679
1797
  this.#viewportTopRow = nextViewportTop;
@@ -1682,12 +1800,11 @@ export class TUI extends Container {
1682
1800
  this.#previousHeight = height;
1683
1801
  };
1684
1802
 
1685
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1803
+ const debugRedraw = this.#debugRedraw;
1686
1804
  const logRedraw = (reason: string): void => {
1687
1805
  if (!debugRedraw) return;
1688
- const logPath = getDebugLogPath();
1689
1806
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1690
- fs.appendFileSync(logPath, msg);
1807
+ this.#appendDebugRedrawLog(msg);
1691
1808
  };
1692
1809
 
1693
1810
  // First render - just output everything without clearing (assumes clean screen)
@@ -1700,13 +1817,12 @@ export class TUI extends Container {
1700
1817
  // Width changes always need a full re-render because wrapping changes.
1701
1818
  if (widthChanged) {
1702
1819
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1703
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1704
- // In multiplexers a full replay piles the whole transcript back onto
1705
- // scrollback (3J is intentionally skipped). Repaint the viewport only,
1706
- // mirroring the height-change branch. This also neutralizes the fake
1707
- // width change that requestRender(true) injects via #previousWidth = -1,
1708
- // so every force-render call site is safe in multiplexers too.
1709
- multiplexerViewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
1820
+ if (useViewportRepaintPath()) {
1821
+ // In viewport-repaint sessions a full replay can either pile the transcript
1822
+ // back onto scrollback (tmux/screen) or visibly jump to the transcript top
1823
+ // (Windows Terminal). Repaint the viewport only, mirroring the height-change
1824
+ // branch and neutralizing fake width changes from requestRender(true).
1825
+ viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
1710
1826
  } else {
1711
1827
  fullRender(true, "terminal width changed");
1712
1828
  }
@@ -1717,8 +1833,8 @@ export class TUI extends Container {
1717
1833
  // but Termux changes height when the software keyboard shows or hides.
1718
1834
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1719
1835
  if (heightChanged) {
1720
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1721
- multiplexerViewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1836
+ if (useViewportRepaintPath()) {
1837
+ viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1722
1838
  return;
1723
1839
  }
1724
1840
  if (!isTermuxSession() && !isMultiplexerSession()) {
@@ -1733,7 +1849,11 @@ export class TUI extends Container {
1733
1849
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1734
1850
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1735
1851
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1736
- fullRender(true, "clearOnShrink");
1852
+ if (useViewportRepaintPath()) {
1853
+ viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1854
+ } else {
1855
+ fullRender(true, "clearOnShrink");
1856
+ }
1737
1857
  return;
1738
1858
  }
1739
1859
 
@@ -1771,6 +1891,11 @@ export class TUI extends Container {
1771
1891
  return;
1772
1892
  }
1773
1893
 
1894
+ const nextLiveViewportTop = Math.max(0, newLines.length - height);
1895
+ if (firstChanged >= newLines.length && nextLiveViewportTop !== prevViewportTop) {
1896
+ viewportRepaint(`tail shrink changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
1897
+ return;
1898
+ }
1774
1899
  // All changes are in deleted lines (nothing to render, just clear)
1775
1900
  if (firstChanged >= newLines.length) {
1776
1901
  if (this.#previousLines.length > newLines.length) {
@@ -1785,8 +1910,8 @@ export class TUI extends Container {
1785
1910
  const extraLines = this.#previousLines.length - newLines.length;
1786
1911
  if (extraLines > height) {
1787
1912
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1788
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1789
- multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1913
+ if (useViewportRepaintPath()) {
1914
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1790
1915
  } else {
1791
1916
  fullRender(true, "extraLines > height");
1792
1917
  }
@@ -1819,16 +1944,19 @@ export class TUI extends Container {
1819
1944
  return;
1820
1945
  }
1821
1946
 
1822
- // Differential rendering can only touch what was actually visible.
1823
- // Any change above the previous viewport requires a full redraw so terminal
1824
- // scrollback ends up consistent with the new transcript state.
1947
+ // Differential rendering can only touch what was actually visible. If a
1948
+ // streaming status/header line changes above a live-following viewport, keep
1949
+ // the terminal pinned by diffing from the visible top instead of clearing and
1950
+ // replaying the transcript. If the user paged away, keep the historical
1951
+ // full-redraw behavior so scrollback is repaired rather than snapping them
1952
+ // back to live.
1825
1953
  if (firstChanged < prevViewportTop) {
1826
1954
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1827
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1828
- multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1829
- } else {
1830
- fullRender(true, "firstChanged < viewportTop");
1955
+ if (useViewportRepaintPath()) {
1956
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1957
+ return;
1831
1958
  }
1959
+ fullRender(true, "firstChanged < viewportTop");
1832
1960
  return;
1833
1961
  }
1834
1962
 
@@ -1869,16 +1997,17 @@ export class TUI extends Container {
1869
1997
  const line = newLines[i];
1870
1998
  let truncatedLine = line;
1871
1999
  const isImage = TERMINAL.isImageLine(line);
1872
- if (!isImage && visibleWidth(line) > width) {
2000
+ const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
2001
+ if (!isImage && lineWidth > width) {
1873
2002
  if (debugRedraw) {
1874
2003
  const debugData = [
1875
2004
  `[TUI Truncate] ${new Date().toISOString()}`,
1876
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
2005
+ `Line ${i} truncated: ${lineWidth} > ${width}`,
1877
2006
  `Content preview: ${line.slice(0, 100)}...`,
1878
2007
  "",
1879
2008
  ].join("\n");
1880
2009
  try {
1881
- fs.appendFileSync(getDebugLogPath(), debugData);
2010
+ this.#appendDebugRedrawLog(debugData);
1882
2011
  } catch {
1883
2012
  // Ignore write errors - truncation should still work
1884
2013
  }