@sayknow-cli/tui 0.3.7 → 0.3.8

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 "@sayknow-cli/utils";
7
+ import { $flag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@sayknow-cli/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";
@@ -139,15 +141,47 @@ function isTermuxSession(): boolean {
139
141
  return Boolean(process.env.TERMUX_VERSION);
140
142
  }
141
143
 
144
+ const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
145
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
146
+
147
+ function envIsEnabled(value: string | undefined): boolean {
148
+ const normalized = value?.trim().toLowerCase();
149
+ return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
150
+ }
151
+
152
+ function termLooksMultiplexed(value: string | undefined): boolean {
153
+ const term = value?.trim().toLowerCase() ?? "";
154
+ return term.startsWith("tmux") || term.startsWith("screen");
155
+ }
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
+
142
165
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
143
166
  function isMultiplexerSession(): boolean {
144
- return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
167
+ return Boolean(
168
+ envIsEnabled(Bun.env.TMUX) ||
169
+ envIsEnabled(Bun.env.TMUX_PANE) ||
170
+ envIsEnabled(Bun.env.STY) ||
171
+ envIsEnabled(Bun.env.ZELLIJ) ||
172
+ envIsEnabled(Bun.env[SKC_TMUX_LAUNCHED_ENV]) ||
173
+ termLooksMultiplexed(Bun.env.TERM),
174
+ );
145
175
  }
146
176
 
147
177
  function useLegacyMultiplexerFullRender(): boolean {
148
178
  return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
149
179
  }
150
180
 
181
+ function useViewportRepaintPath(): boolean {
182
+ return isViewportRepaintSession() && !(isMultiplexerSession() && useLegacyMultiplexerFullRender());
183
+ }
184
+
151
185
  /**
152
186
  * Options for overlay positioning and sizing.
153
187
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -303,6 +337,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
303
337
  type LineNormalizationCacheEntry = {
304
338
  normalized: string;
305
339
  terminated: string;
340
+ width: number | undefined;
341
+ };
342
+
343
+ type TuiRenderCounterSnapshot = {
344
+ debugRedrawEnvReads: number;
345
+ debugRedrawAppendWrites: number;
346
+ differentialGuardVisibleWidthCalls: number;
306
347
  };
307
348
 
308
349
  /**
@@ -319,6 +360,7 @@ export class TUI extends Container {
319
360
  */
320
361
  #previousRaw: string[] = [];
321
362
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
363
+ #lineEmitWidthCache = new Map<string, number>();
322
364
  #lineTruncationCache = new Map<string, string>();
323
365
  #lineNormalizationCacheLimit = 0;
324
366
  #lineTruncationCacheLimit = 0;
@@ -349,20 +391,58 @@ export class TUI extends Container {
349
391
  #sixelProbeTimeout?: NodeJS.Timeout;
350
392
  #sixelProbeUnsubscribe?: () => void;
351
393
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
394
+ #debugRedraw = TUI.#readDebugRedrawFlag();
352
395
  // macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
353
396
  readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
354
397
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
355
398
  #imeCursorActive = false;
356
399
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
357
- // Opt-in: reuse the previous normalized off-screen prefix and only normalize/diff the
358
- // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical.
359
- #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);
360
404
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
361
405
  #fullRedrawCount = 0;
362
406
  #stopped = false;
363
407
  #terminalUnavailable = false;
364
408
  #bottomPinnedComponent: Component | null = null;
365
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
+
366
446
  // Overlay stack for modal components rendered on top of base content
367
447
  overlayStack: {
368
448
  component: Component;
@@ -378,6 +458,18 @@ export class TUI extends Container {
378
458
  this.#showHardwareCursor = showHardwareCursor;
379
459
  }
380
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();
381
473
  }
382
474
 
383
475
  get fullRedraws(): number {
@@ -809,24 +901,26 @@ export class TUI extends Container {
809
901
  this.#previousRaw = [];
810
902
  this.#lineNormalizationCache.clear();
811
903
  this.#lineTruncationCache.clear();
904
+ this.#lineEmitWidthCache.clear();
812
905
  this.#previousWidth = 0;
813
906
  this.#previousHeight = 0;
814
907
  }
815
908
 
816
909
  /**
817
- * Multiplexer-aware resize render request.
910
+ * Viewport-repaint-aware resize render request.
818
911
  *
819
912
  * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
820
913
  * to -1, which makes `#doRender` treat the frame as a width change and fall into the
821
914
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
822
915
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
823
916
  * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
824
- * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
825
- * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
826
- * 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.
827
921
  */
828
922
  requestResizeRender(): void {
829
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
923
+ this.requestRender(!useViewportRepaintPath() && !isTermuxSession(), "resize");
830
924
  }
831
925
 
832
926
  requestRender(force = false, source = "unknown"): void {
@@ -836,20 +930,24 @@ export class TUI extends Container {
836
930
  }
837
931
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
838
932
  if (force) {
933
+ const preserveViewportCursor = useViewportRepaintPath();
839
934
  // A forced full redraw supersedes any queued input-priority render.
840
935
  this.#inputRenderPending = false;
841
936
  this.#previousLines = [];
842
937
  this.#previousRaw = [];
843
938
  this.#lineNormalizationCache.clear();
844
939
  this.#lineTruncationCache.clear();
940
+ this.#lineEmitWidthCache.clear();
845
941
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
846
942
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
847
943
  this.#lineNormalizationCacheLimit = 0;
848
944
  this.#lineTruncationCacheLimit = 0;
849
- this.#cursorRow = 0;
850
- this.#hardwareCursorRow = 0;
851
- this.#viewportTopRow = 0;
852
- this.#maxLinesRendered = 0;
945
+ if (!preserveViewportCursor) {
946
+ this.#cursorRow = 0;
947
+ this.#hardwareCursorRow = 0;
948
+ this.#viewportTopRow = 0;
949
+ this.#maxLinesRendered = 0;
950
+ }
853
951
  if (this.#renderTimer) {
854
952
  clearTimeout(this.#renderTimer);
855
953
  this.#renderTimer = undefined;
@@ -1315,24 +1413,9 @@ export class TUI extends Container {
1315
1413
  if (cached !== undefined) return cached;
1316
1414
  const normalized = normalizeTerminalOutput(line);
1317
1415
  const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1318
- this.#lineNormalizationCache.set(line, { normalized, terminated });
1319
- return { normalized, terminated };
1320
- }
1321
-
1322
- #lineFitsWidth(normalizedLine: string, width: number): boolean {
1323
- return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
1324
- ? true
1325
- : visibleWidth(normalizedLine) <= width;
1326
- }
1327
-
1328
- #truncateNormalizedLine(normalizedLine: string, width: number): string {
1329
- const key = `${width}\0${normalizedLine}`;
1330
- const cached = this.#lineTruncationCache.get(key);
1331
- if (cached !== undefined) return cached;
1332
- const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
1333
- const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1334
- this.#lineTruncationCache.set(key, terminated);
1335
- return terminated;
1416
+ const entry = { normalized, terminated, width: undefined };
1417
+ this.#lineNormalizationCache.set(line, entry);
1418
+ return entry;
1336
1419
  }
1337
1420
 
1338
1421
  #trimLineCachesForRender(lineCount: number): void {
@@ -1342,6 +1425,8 @@ export class TUI extends Container {
1342
1425
  while (this.#lineNormalizationCache.size > limit) {
1343
1426
  const key = this.#lineNormalizationCache.keys().next().value;
1344
1427
  if (key === undefined) break;
1428
+ const entry = this.#lineNormalizationCache.get(key);
1429
+ if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
1345
1430
  this.#lineNormalizationCache.delete(key);
1346
1431
  }
1347
1432
  while (this.#lineTruncationCache.size > limit) {
@@ -1349,6 +1434,11 @@ export class TUI extends Container {
1349
1434
  if (key === undefined) break;
1350
1435
  this.#lineTruncationCache.delete(key);
1351
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
+ }
1352
1442
  }
1353
1443
 
1354
1444
  getLineRenderCacheStats(): {
@@ -1365,17 +1455,66 @@ export class TUI extends Container {
1365
1455
  };
1366
1456
  }
1367
1457
 
1368
- /** Normalize + width-fit a single line for emission (image lines pass through). */
1369
- #normalizeLineForEmit(line: string, width: number): string {
1370
- if (TERMINAL.isImageLine(line)) return line;
1371
- const { normalized, terminated } = this.#normalizeLineForRender(line);
1372
- 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;
1373
1514
  }
1374
1515
 
1375
1516
  #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1376
- for (let i = 0; i < lines.length; i++) {
1377
- lines[i] = this.#normalizeLineForEmit(lines[i], width);
1378
- }
1517
+ this.#normalizeLinesForEmit(lines, width);
1379
1518
  this.#trimLineCachesForRender(lines.length);
1380
1519
  return lines;
1381
1520
  }
@@ -1429,7 +1568,7 @@ export class TUI extends Container {
1429
1568
  if (lineIndex >= lines.length) continue;
1430
1569
  const line = lines[lineIndex];
1431
1570
  const isImage = TERMINAL.isImageLine(line);
1432
- if (!isImage && visibleWidth(line) > width) {
1571
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1433
1572
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1434
1573
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1435
1574
  buffer += truncatedLine;
@@ -1451,10 +1590,9 @@ export class TUI extends Container {
1451
1590
  buffer += "\x1b[?2026l";
1452
1591
  if (!this.#writeTerminal(buffer)) return false;
1453
1592
 
1454
- if ($flag("PI_DEBUG_REDRAW")) {
1455
- const logPath = getDebugLogPath();
1593
+ if (this.#debugRedraw) {
1456
1594
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1457
- fs.appendFileSync(logPath, msg);
1595
+ this.#appendDebugRedrawLog(msg);
1458
1596
  }
1459
1597
 
1460
1598
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1531,8 +1669,9 @@ export class TUI extends Container {
1531
1669
  if (stable) {
1532
1670
  const windowed = this.#previousLines.slice(0, winTop);
1533
1671
  for (let i = winTop; i < total; i++) {
1534
- windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
1672
+ windowed.push(rawLines[i]);
1535
1673
  }
1674
+ this.#normalizeLinesForEmit(windowed, width, winTop);
1536
1675
  this.#trimLineCachesForRender(total);
1537
1676
  newLines = windowed;
1538
1677
  diffStart = winTop;
@@ -1606,7 +1745,7 @@ export class TUI extends Container {
1606
1745
  this.#previousHeight = height;
1607
1746
  };
1608
1747
 
1609
- const multiplexerViewportRepaint = (reason: string): void => {
1748
+ const viewportRepaint = (reason: string): void => {
1610
1749
  this.#fullRedrawCount += 1;
1611
1750
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1612
1751
  const nextViewportTop = Math.max(0, newLines.length - height);
@@ -1623,7 +1762,7 @@ export class TUI extends Container {
1623
1762
  if (lineIndex >= newLines.length) continue;
1624
1763
  const line = newLines[lineIndex];
1625
1764
  const isImage = TERMINAL.isImageLine(line);
1626
- if (!isImage && visibleWidth(line) > width) {
1765
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1627
1766
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1628
1767
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1629
1768
  buffer += truncatedLine;
@@ -1645,15 +1784,14 @@ export class TUI extends Container {
1645
1784
  buffer += "\x1b[?2026l";
1646
1785
  if (!this.#writeTerminal(buffer)) return;
1647
1786
 
1648
- if ($flag("PI_DEBUG_REDRAW")) {
1649
- const logPath = getDebugLogPath();
1650
- const msg = `[${new Date().toISOString()}] multiplexerViewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1651
- 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);
1652
1790
  }
1653
- // In multiplexers this deliberately prioritizes the live viewport over
1791
+ // Viewport repaint deliberately prioritizes the live viewport over
1654
1792
  // historical scrollback repair. After offscreen changes, #previousLines
1655
1793
  // tracks the desired logical transcript, not every byte emitted into the
1656
- // multiplexer scrollback.
1794
+ // terminal scrollback.
1657
1795
  this.#cursorRow = Math.max(0, newLines.length - 1);
1658
1796
  this.#maxLinesRendered = newLines.length;
1659
1797
  this.#viewportTopRow = nextViewportTop;
@@ -1662,12 +1800,11 @@ export class TUI extends Container {
1662
1800
  this.#previousHeight = height;
1663
1801
  };
1664
1802
 
1665
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1803
+ const debugRedraw = this.#debugRedraw;
1666
1804
  const logRedraw = (reason: string): void => {
1667
1805
  if (!debugRedraw) return;
1668
- const logPath = getDebugLogPath();
1669
1806
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1670
- fs.appendFileSync(logPath, msg);
1807
+ this.#appendDebugRedrawLog(msg);
1671
1808
  };
1672
1809
 
1673
1810
  // First render - just output everything without clearing (assumes clean screen)
@@ -1680,13 +1817,12 @@ export class TUI extends Container {
1680
1817
  // Width changes always need a full re-render because wrapping changes.
1681
1818
  if (widthChanged) {
1682
1819
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1683
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1684
- // In multiplexers a full replay piles the whole transcript back onto
1685
- // scrollback (3J is intentionally skipped). Repaint the viewport only,
1686
- // mirroring the height-change branch. This also neutralizes the fake
1687
- // width change that requestRender(true) injects via #previousWidth = -1,
1688
- // so every force-render call site is safe in multiplexers too.
1689
- 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})`);
1690
1826
  } else {
1691
1827
  fullRender(true, "terminal width changed");
1692
1828
  }
@@ -1697,8 +1833,8 @@ export class TUI extends Container {
1697
1833
  // but Termux changes height when the software keyboard shows or hides.
1698
1834
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1699
1835
  if (heightChanged) {
1700
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1701
- multiplexerViewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1836
+ if (useViewportRepaintPath()) {
1837
+ viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1702
1838
  return;
1703
1839
  }
1704
1840
  if (!isTermuxSession() && !isMultiplexerSession()) {
@@ -1713,7 +1849,11 @@ export class TUI extends Container {
1713
1849
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1714
1850
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1715
1851
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1716
- fullRender(true, "clearOnShrink");
1852
+ if (useViewportRepaintPath()) {
1853
+ viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1854
+ } else {
1855
+ fullRender(true, "clearOnShrink");
1856
+ }
1717
1857
  return;
1718
1858
  }
1719
1859
 
@@ -1751,6 +1891,11 @@ export class TUI extends Container {
1751
1891
  return;
1752
1892
  }
1753
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
+ }
1754
1899
  // All changes are in deleted lines (nothing to render, just clear)
1755
1900
  if (firstChanged >= newLines.length) {
1756
1901
  if (this.#previousLines.length > newLines.length) {
@@ -1765,8 +1910,8 @@ export class TUI extends Container {
1765
1910
  const extraLines = this.#previousLines.length - newLines.length;
1766
1911
  if (extraLines > height) {
1767
1912
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1768
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1769
- multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1913
+ if (useViewportRepaintPath()) {
1914
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1770
1915
  } else {
1771
1916
  fullRender(true, "extraLines > height");
1772
1917
  }
@@ -1799,16 +1944,19 @@ export class TUI extends Container {
1799
1944
  return;
1800
1945
  }
1801
1946
 
1802
- // Differential rendering can only touch what was actually visible.
1803
- // Any change above the previous viewport requires a full redraw so terminal
1804
- // 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.
1805
1953
  if (firstChanged < prevViewportTop) {
1806
1954
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1807
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1808
- multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1809
- } else {
1810
- fullRender(true, "firstChanged < viewportTop");
1955
+ if (useViewportRepaintPath()) {
1956
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1957
+ return;
1811
1958
  }
1959
+ fullRender(true, "firstChanged < viewportTop");
1812
1960
  return;
1813
1961
  }
1814
1962
 
@@ -1849,16 +1997,17 @@ export class TUI extends Container {
1849
1997
  const line = newLines[i];
1850
1998
  let truncatedLine = line;
1851
1999
  const isImage = TERMINAL.isImageLine(line);
1852
- if (!isImage && visibleWidth(line) > width) {
2000
+ const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
2001
+ if (!isImage && lineWidth > width) {
1853
2002
  if (debugRedraw) {
1854
2003
  const debugData = [
1855
2004
  `[TUI Truncate] ${new Date().toISOString()}`,
1856
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
2005
+ `Line ${i} truncated: ${lineWidth} > ${width}`,
1857
2006
  `Content preview: ${line.slice(0, 100)}...`,
1858
2007
  "",
1859
2008
  ].join("\n");
1860
2009
  try {
1861
- fs.appendFileSync(getDebugLogPath(), debugData);
2010
+ this.#appendDebugRedrawLog(debugData);
1862
2011
  } catch {
1863
2012
  // Ignore write errors - truncation should still work
1864
2013
  }