@sayknow-cli/tui 0.3.7 → 0.3.9

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";
@@ -135,17 +137,83 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
135
137
  return undefined;
136
138
  }
137
139
 
138
- function isTermuxSession(): boolean {
139
- return Boolean(process.env.TERMUX_VERSION);
140
+ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boolean {
141
+ return Boolean(env.TERMUX_VERSION);
142
+ }
143
+
144
+ const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
145
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
146
+ const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
147
+
148
+ function envIsEnabled(value: string | undefined): boolean {
149
+ const normalized = value?.trim().toLowerCase();
150
+ return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
151
+ }
152
+
153
+ function envFlagEnabled(value: string | undefined): boolean {
154
+ const normalized = value?.trim().toLowerCase();
155
+ return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
156
+ }
157
+
158
+ function termLooksMultiplexed(value: string | undefined): boolean {
159
+ const term = value?.trim().toLowerCase() ?? "";
160
+ return term.startsWith("tmux") || term.startsWith("screen");
161
+ }
162
+
163
+ function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
164
+ return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
140
165
  }
141
166
 
142
167
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
143
- function isMultiplexerSession(): boolean {
144
- return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
168
+ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
169
+ return Boolean(
170
+ envIsEnabled(env.TMUX) ||
171
+ envIsEnabled(env.TMUX_PANE) ||
172
+ envIsEnabled(env.STY) ||
173
+ envIsEnabled(env.ZELLIJ) ||
174
+ envIsEnabled(env[SKC_TMUX_LAUNCHED_ENV]) ||
175
+ termLooksMultiplexed(env.TERM),
176
+ );
177
+ }
178
+
179
+ function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
180
+ return envFlagEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
181
+ }
182
+
183
+ function isViewportSensitiveHost(
184
+ env: Record<string, string | undefined>,
185
+ platform: NodeJS.Platform,
186
+ includeNativeWindows: boolean,
187
+ ): boolean {
188
+ return isMultiplexerSession(env) || isWindowsTerminalSession(env) || (includeNativeWindows && platform === "win32");
189
+ }
190
+ /**
191
+ * True when repainting only the live viewport is safer than clearing/replaying
192
+ * the full transcript. Native Windows console hosts are included even when
193
+ * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
194
+ * identity variables while keeping the same scroll-jump behavior.
195
+ */
196
+ export function shouldUseViewportRepaintForHost(
197
+ env: Record<string, string | undefined> = Bun.env,
198
+ platform: NodeJS.Platform = process.platform,
199
+ options: { includeNativeWindows?: boolean } = {},
200
+ ): boolean {
201
+ const multiplexed = isMultiplexerSession(env);
202
+ const includeNativeWindows = options.includeNativeWindows ?? true;
203
+ return (
204
+ isViewportSensitiveHost(env, platform, includeNativeWindows) &&
205
+ !(multiplexed && useLegacyMultiplexerFullRender(env))
206
+ );
145
207
  }
146
208
 
147
- function useLegacyMultiplexerFullRender(): boolean {
148
- return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
209
+ function useViewportRepaintPath(terminal: Terminal): boolean {
210
+ return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
211
+ includeNativeWindows: terminal.isProcessTerminal === true,
212
+ });
213
+ }
214
+
215
+ function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
216
+ return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
149
217
  }
150
218
 
151
219
  /**
@@ -303,6 +371,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
303
371
  type LineNormalizationCacheEntry = {
304
372
  normalized: string;
305
373
  terminated: string;
374
+ width: number | undefined;
375
+ };
376
+
377
+ type TuiRenderCounterSnapshot = {
378
+ debugRedrawEnvReads: number;
379
+ debugRedrawAppendWrites: number;
380
+ differentialGuardVisibleWidthCalls: number;
306
381
  };
307
382
 
308
383
  /**
@@ -319,6 +394,7 @@ export class TUI extends Container {
319
394
  */
320
395
  #previousRaw: string[] = [];
321
396
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
397
+ #lineEmitWidthCache = new Map<string, number>();
322
398
  #lineTruncationCache = new Map<string, string>();
323
399
  #lineNormalizationCacheLimit = 0;
324
400
  #lineTruncationCacheLimit = 0;
@@ -349,20 +425,58 @@ export class TUI extends Container {
349
425
  #sixelProbeTimeout?: NodeJS.Timeout;
350
426
  #sixelProbeUnsubscribe?: () => void;
351
427
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
428
+ #debugRedraw = TUI.#readDebugRedrawFlag();
352
429
  // macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
353
430
  readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
354
431
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
355
432
  #imeCursorActive = false;
356
433
  #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");
434
+ // Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
435
+ // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
436
+ // set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
437
+ #virtualViewport = $flag("PI_TUI_VIRTUAL_VIEWPORT", true);
360
438
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
361
439
  #fullRedrawCount = 0;
362
440
  #stopped = false;
363
441
  #terminalUnavailable = false;
364
442
  #bottomPinnedComponent: Component | null = null;
365
443
 
444
+ #unsubscribeTabWidthChange?: () => void;
445
+ static #renderCounters: TuiRenderCounterSnapshot = {
446
+ debugRedrawEnvReads: 0,
447
+ debugRedrawAppendWrites: 0,
448
+ differentialGuardVisibleWidthCalls: 0,
449
+ };
450
+
451
+ static resetRenderCountersForTest(): void {
452
+ TUI.#renderCounters = {
453
+ debugRedrawEnvReads: 0,
454
+ debugRedrawAppendWrites: 0,
455
+ differentialGuardVisibleWidthCalls: 0,
456
+ };
457
+ }
458
+
459
+ static getRenderCountersForTest(): TuiRenderCounterSnapshot {
460
+ return { ...TUI.#renderCounters };
461
+ }
462
+
463
+ static #readDebugRedrawFlag(): boolean {
464
+ TUI.#renderCounters.debugRedrawEnvReads += 1;
465
+ return $flag("PI_DEBUG_REDRAW");
466
+ }
467
+
468
+ #appendDebugRedrawLog(message: string): void {
469
+ TUI.#renderCounters.debugRedrawAppendWrites += 1;
470
+ fs.appendFileSync(getDebugLogPath(), message);
471
+ }
472
+
473
+ #visibleWidthForDifferentialGuard(line: string): number {
474
+ const cached = this.#lineEmitWidthCache.get(line);
475
+ if (cached !== undefined) return cached;
476
+ TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
477
+ return visibleWidth(line);
478
+ }
479
+
366
480
  // Overlay stack for modal components rendered on top of base content
367
481
  overlayStack: {
368
482
  component: Component;
@@ -378,6 +492,18 @@ export class TUI extends Container {
378
492
  this.#showHardwareCursor = showHardwareCursor;
379
493
  }
380
494
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
495
+ this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
496
+ this.#lineTruncationCache.clear();
497
+ this.#lineNormalizationCache.clear();
498
+ this.#lineEmitWidthCache.clear();
499
+ this.requestRender(true, "tab-width-change");
500
+ });
501
+ }
502
+
503
+ override dispose(): void {
504
+ this.#unsubscribeTabWidthChange?.();
505
+ this.#unsubscribeTabWidthChange = undefined;
506
+ super.dispose();
381
507
  }
382
508
 
383
509
  get fullRedraws(): number {
@@ -438,19 +564,13 @@ export class TUI extends Container {
438
564
  const pageStep = Math.max(1, height - 1);
439
565
  const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
440
566
 
441
- if (targetViewportTop >= maxViewportTop) {
442
- this.#manualViewportTop = undefined;
443
- } else {
444
- this.#manualViewportTop = targetViewportTop;
445
- }
446
-
447
- const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
567
+ this.#manualViewportTop = targetViewportTop;
448
568
  return this.#repaintViewportFromLines(
449
569
  this.#previousLines,
450
570
  width,
451
571
  height,
452
572
  targetViewportTop,
453
- cursorPos,
573
+ null,
454
574
  "manual viewport scroll",
455
575
  );
456
576
  }
@@ -809,24 +929,26 @@ export class TUI extends Container {
809
929
  this.#previousRaw = [];
810
930
  this.#lineNormalizationCache.clear();
811
931
  this.#lineTruncationCache.clear();
932
+ this.#lineEmitWidthCache.clear();
812
933
  this.#previousWidth = 0;
813
934
  this.#previousHeight = 0;
814
935
  }
815
936
 
816
937
  /**
817
- * Multiplexer-aware resize render request.
938
+ * Viewport-repaint-aware resize render request.
818
939
  *
819
940
  * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
820
941
  * to -1, which makes `#doRender` treat the frame as a width change and fall into the
821
942
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
822
943
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
823
944
  * 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.
945
+ * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
946
+ * the transcript top during streaming redraws, so viewport-repaint sessions
947
+ * keep force off and let `#doRender` repaint only the live viewport. Set
948
+ * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
827
949
  */
828
950
  requestResizeRender(): void {
829
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
951
+ this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
830
952
  }
831
953
 
832
954
  requestRender(force = false, source = "unknown"): void {
@@ -836,20 +958,24 @@ export class TUI extends Container {
836
958
  }
837
959
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
838
960
  if (force) {
961
+ const preserveViewportCursor = useViewportRepaintPath(this.terminal);
839
962
  // A forced full redraw supersedes any queued input-priority render.
840
963
  this.#inputRenderPending = false;
841
964
  this.#previousLines = [];
842
965
  this.#previousRaw = [];
843
966
  this.#lineNormalizationCache.clear();
844
967
  this.#lineTruncationCache.clear();
968
+ this.#lineEmitWidthCache.clear();
845
969
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
846
970
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
847
971
  this.#lineNormalizationCacheLimit = 0;
848
972
  this.#lineTruncationCacheLimit = 0;
849
- this.#cursorRow = 0;
850
- this.#hardwareCursorRow = 0;
851
- this.#viewportTopRow = 0;
852
- this.#maxLinesRendered = 0;
973
+ if (!preserveViewportCursor) {
974
+ this.#cursorRow = 0;
975
+ this.#hardwareCursorRow = 0;
976
+ this.#viewportTopRow = 0;
977
+ this.#maxLinesRendered = 0;
978
+ }
853
979
  if (this.#renderTimer) {
854
980
  clearTimeout(this.#renderTimer);
855
981
  this.#renderTimer = undefined;
@@ -1315,24 +1441,9 @@ export class TUI extends Container {
1315
1441
  if (cached !== undefined) return cached;
1316
1442
  const normalized = normalizeTerminalOutput(line);
1317
1443
  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;
1444
+ const entry = { normalized, terminated, width: undefined };
1445
+ this.#lineNormalizationCache.set(line, entry);
1446
+ return entry;
1336
1447
  }
1337
1448
 
1338
1449
  #trimLineCachesForRender(lineCount: number): void {
@@ -1342,6 +1453,8 @@ export class TUI extends Container {
1342
1453
  while (this.#lineNormalizationCache.size > limit) {
1343
1454
  const key = this.#lineNormalizationCache.keys().next().value;
1344
1455
  if (key === undefined) break;
1456
+ const entry = this.#lineNormalizationCache.get(key);
1457
+ if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
1345
1458
  this.#lineNormalizationCache.delete(key);
1346
1459
  }
1347
1460
  while (this.#lineTruncationCache.size > limit) {
@@ -1349,6 +1462,11 @@ export class TUI extends Container {
1349
1462
  if (key === undefined) break;
1350
1463
  this.#lineTruncationCache.delete(key);
1351
1464
  }
1465
+ while (this.#lineEmitWidthCache.size > limit * 2) {
1466
+ const key = this.#lineEmitWidthCache.keys().next().value;
1467
+ if (key === undefined) break;
1468
+ this.#lineEmitWidthCache.delete(key);
1469
+ }
1352
1470
  }
1353
1471
 
1354
1472
  getLineRenderCacheStats(): {
@@ -1365,17 +1483,66 @@ export class TUI extends Container {
1365
1483
  };
1366
1484
  }
1367
1485
 
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);
1486
+ #normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
1487
+ const widthCheckIndexes: number[] = [];
1488
+ const widthCheckLines: string[] = [];
1489
+ for (let i = start; i < lines.length; i++) {
1490
+ const line = lines[i];
1491
+ if (TERMINAL.isImageLine(line)) continue;
1492
+ const entry = this.#normalizeLineForRender(line);
1493
+ const { normalized, terminated } = entry;
1494
+ if (isPrintableAscii(normalized) && normalized.length <= width) {
1495
+ entry.width = normalized.length;
1496
+ this.#lineEmitWidthCache.set(terminated, normalized.length);
1497
+ lines[i] = terminated;
1498
+ continue;
1499
+ }
1500
+ widthCheckIndexes.push(i);
1501
+ widthCheckLines.push(normalized);
1502
+ }
1503
+
1504
+ const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
1505
+ const truncateIndexes: number[] = [];
1506
+ const truncateLines: string[] = [];
1507
+ for (let i = 0; i < widthCheckIndexes.length; i++) {
1508
+ const lineIndex = widthCheckIndexes[i];
1509
+ const normalized = widthCheckLines[i];
1510
+ const measuredWidth = widths[i] ?? 0;
1511
+ if (measuredWidth <= width) {
1512
+ const entry = this.#normalizeLineForRender(lines[lineIndex]);
1513
+ entry.width = measuredWidth;
1514
+ this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
1515
+ lines[lineIndex] = entry.terminated;
1516
+ continue;
1517
+ }
1518
+
1519
+ const key = `${width}\0${normalized}`;
1520
+ const cached = this.#lineTruncationCache.get(key);
1521
+ if (cached !== undefined) {
1522
+ this.#lineEmitWidthCache.set(cached, width);
1523
+ lines[lineIndex] = cached;
1524
+ continue;
1525
+ }
1526
+ truncateIndexes.push(lineIndex);
1527
+ truncateLines.push(normalized);
1528
+ }
1529
+
1530
+ const truncated = truncateLines.length === 0 ? [] : truncateLinesToWidth(truncateLines, width, Ellipsis.Omit);
1531
+ for (let i = 0; i < truncateIndexes.length; i++) {
1532
+ const lineIndex = truncateIndexes[i];
1533
+ const normalized = truncateLines[i];
1534
+ const truncatedLine = truncated[i] ?? "";
1535
+ const terminated = truncatedLine + (truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1536
+ this.#lineTruncationCache.set(`${width}\0${normalized}`, terminated);
1537
+ this.#lineEmitWidthCache.set(terminated, width);
1538
+ lines[lineIndex] = terminated;
1539
+ }
1540
+
1541
+ return lines;
1373
1542
  }
1374
1543
 
1375
1544
  #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1376
- for (let i = 0; i < lines.length; i++) {
1377
- lines[i] = this.#normalizeLineForEmit(lines[i], width);
1378
- }
1545
+ this.#normalizeLinesForEmit(lines, width);
1379
1546
  this.#trimLineCachesForRender(lines.length);
1380
1547
  return lines;
1381
1548
  }
@@ -1429,7 +1596,7 @@ export class TUI extends Container {
1429
1596
  if (lineIndex >= lines.length) continue;
1430
1597
  const line = lines[lineIndex];
1431
1598
  const isImage = TERMINAL.isImageLine(line);
1432
- if (!isImage && visibleWidth(line) > width) {
1599
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1433
1600
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1434
1601
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1435
1602
  buffer += truncatedLine;
@@ -1451,10 +1618,9 @@ export class TUI extends Container {
1451
1618
  buffer += "\x1b[?2026l";
1452
1619
  if (!this.#writeTerminal(buffer)) return false;
1453
1620
 
1454
- if ($flag("PI_DEBUG_REDRAW")) {
1455
- const logPath = getDebugLogPath();
1621
+ if (this.#debugRedraw) {
1456
1622
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1457
- fs.appendFileSync(logPath, msg);
1623
+ this.#appendDebugRedrawLog(msg);
1458
1624
  }
1459
1625
 
1460
1626
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1531,8 +1697,9 @@ export class TUI extends Container {
1531
1697
  if (stable) {
1532
1698
  const windowed = this.#previousLines.slice(0, winTop);
1533
1699
  for (let i = winTop; i < total; i++) {
1534
- windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
1700
+ windowed.push(rawLines[i]);
1535
1701
  }
1702
+ this.#normalizeLinesForEmit(windowed, width, winTop);
1536
1703
  this.#trimLineCachesForRender(total);
1537
1704
  newLines = windowed;
1538
1705
  diffStart = winTop;
@@ -1556,9 +1723,8 @@ export class TUI extends Container {
1556
1723
  if (this.#manualViewportTop !== undefined) {
1557
1724
  const maxViewportTop = Math.max(0, newLines.length - height);
1558
1725
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
1559
- const followingLive = nextViewportTop >= maxViewportTop;
1560
- this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
1561
- const repaintCursorPos = followingLive ? cursorPos : null;
1726
+ this.#manualViewportTop = nextViewportTop;
1727
+ const repaintCursorPos = null;
1562
1728
  if (
1563
1729
  this.#repaintViewportFromLines(
1564
1730
  newLines,
@@ -1580,8 +1746,10 @@ export class TUI extends Container {
1580
1746
  this.#fullRedrawCount += 1;
1581
1747
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1582
1748
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1583
- // Skip clearing scrollback (3J) in multiplexers users actively navigate scrollback history
1584
- if (clear) buffer += isMultiplexerSession() ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1749
+ // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
1750
+ // native viewport away from the live prompt (tmux/screen, Windows ConPTY).
1751
+ if (clear)
1752
+ buffer += shouldPreserveScrollbackOnFullClear(this.terminal) ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1585
1753
  for (let i = 0; i < newLines.length; i++) {
1586
1754
  if (i > 0) buffer += "\r\n";
1587
1755
  // Lines were pre-terminated/normalized by #applyLineResets; image
@@ -1606,7 +1774,7 @@ export class TUI extends Container {
1606
1774
  this.#previousHeight = height;
1607
1775
  };
1608
1776
 
1609
- const multiplexerViewportRepaint = (reason: string): void => {
1777
+ const viewportRepaint = (reason: string): void => {
1610
1778
  this.#fullRedrawCount += 1;
1611
1779
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1612
1780
  const nextViewportTop = Math.max(0, newLines.length - height);
@@ -1623,7 +1791,7 @@ export class TUI extends Container {
1623
1791
  if (lineIndex >= newLines.length) continue;
1624
1792
  const line = newLines[lineIndex];
1625
1793
  const isImage = TERMINAL.isImageLine(line);
1626
- if (!isImage && visibleWidth(line) > width) {
1794
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1627
1795
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1628
1796
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1629
1797
  buffer += truncatedLine;
@@ -1645,15 +1813,14 @@ export class TUI extends Container {
1645
1813
  buffer += "\x1b[?2026l";
1646
1814
  if (!this.#writeTerminal(buffer)) return;
1647
1815
 
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);
1816
+ if (this.#debugRedraw) {
1817
+ const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1818
+ this.#appendDebugRedrawLog(msg);
1652
1819
  }
1653
- // In multiplexers this deliberately prioritizes the live viewport over
1820
+ // Viewport repaint deliberately prioritizes the live viewport over
1654
1821
  // historical scrollback repair. After offscreen changes, #previousLines
1655
1822
  // tracks the desired logical transcript, not every byte emitted into the
1656
- // multiplexer scrollback.
1823
+ // terminal scrollback.
1657
1824
  this.#cursorRow = Math.max(0, newLines.length - 1);
1658
1825
  this.#maxLinesRendered = newLines.length;
1659
1826
  this.#viewportTopRow = nextViewportTop;
@@ -1662,12 +1829,11 @@ export class TUI extends Container {
1662
1829
  this.#previousHeight = height;
1663
1830
  };
1664
1831
 
1665
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1832
+ const debugRedraw = this.#debugRedraw;
1666
1833
  const logRedraw = (reason: string): void => {
1667
1834
  if (!debugRedraw) return;
1668
- const logPath = getDebugLogPath();
1669
1835
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1670
- fs.appendFileSync(logPath, msg);
1836
+ this.#appendDebugRedrawLog(msg);
1671
1837
  };
1672
1838
 
1673
1839
  // First render - just output everything without clearing (assumes clean screen)
@@ -1680,13 +1846,12 @@ export class TUI extends Container {
1680
1846
  // Width changes always need a full re-render because wrapping changes.
1681
1847
  if (widthChanged) {
1682
1848
  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})`);
1849
+ if (useViewportRepaintPath(this.terminal)) {
1850
+ // In viewport-repaint sessions a full replay can either pile the transcript
1851
+ // back onto scrollback (tmux/screen) or visibly jump to the transcript top
1852
+ // (Windows Terminal). Repaint the viewport only, mirroring the height-change
1853
+ // branch and neutralizing fake width changes from requestRender(true).
1854
+ viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
1690
1855
  } else {
1691
1856
  fullRender(true, "terminal width changed");
1692
1857
  }
@@ -1697,8 +1862,8 @@ export class TUI extends Container {
1697
1862
  // but Termux changes height when the software keyboard shows or hides.
1698
1863
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1699
1864
  if (heightChanged) {
1700
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1701
- multiplexerViewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1865
+ if (useViewportRepaintPath(this.terminal)) {
1866
+ viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1702
1867
  return;
1703
1868
  }
1704
1869
  if (!isTermuxSession() && !isMultiplexerSession()) {
@@ -1713,7 +1878,11 @@ export class TUI extends Container {
1713
1878
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1714
1879
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1715
1880
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1716
- fullRender(true, "clearOnShrink");
1881
+ if (useViewportRepaintPath(this.terminal)) {
1882
+ viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1883
+ } else {
1884
+ fullRender(true, "clearOnShrink");
1885
+ }
1717
1886
  return;
1718
1887
  }
1719
1888
 
@@ -1751,6 +1920,11 @@ export class TUI extends Container {
1751
1920
  return;
1752
1921
  }
1753
1922
 
1923
+ const nextLiveViewportTop = Math.max(0, newLines.length - height);
1924
+ if (firstChanged >= newLines.length && nextLiveViewportTop !== prevViewportTop) {
1925
+ viewportRepaint(`tail shrink changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
1926
+ return;
1927
+ }
1754
1928
  // All changes are in deleted lines (nothing to render, just clear)
1755
1929
  if (firstChanged >= newLines.length) {
1756
1930
  if (this.#previousLines.length > newLines.length) {
@@ -1765,8 +1939,8 @@ export class TUI extends Container {
1765
1939
  const extraLines = this.#previousLines.length - newLines.length;
1766
1940
  if (extraLines > height) {
1767
1941
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1768
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1769
- multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1942
+ if (useViewportRepaintPath(this.terminal)) {
1943
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1770
1944
  } else {
1771
1945
  fullRender(true, "extraLines > height");
1772
1946
  }
@@ -1799,16 +1973,19 @@ export class TUI extends Container {
1799
1973
  return;
1800
1974
  }
1801
1975
 
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.
1976
+ // Differential rendering can only touch what was actually visible. If a
1977
+ // streaming status/header line changes above a live-following viewport, keep
1978
+ // the terminal pinned by diffing from the visible top instead of clearing and
1979
+ // replaying the transcript. If the user paged away, keep the historical
1980
+ // full-redraw behavior so scrollback is repaired rather than snapping them
1981
+ // back to live.
1805
1982
  if (firstChanged < prevViewportTop) {
1806
1983
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1807
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1808
- multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1809
- } else {
1810
- fullRender(true, "firstChanged < viewportTop");
1984
+ if (useViewportRepaintPath(this.terminal)) {
1985
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1986
+ return;
1811
1987
  }
1988
+ fullRender(true, "firstChanged < viewportTop");
1812
1989
  return;
1813
1990
  }
1814
1991
 
@@ -1849,16 +2026,17 @@ export class TUI extends Container {
1849
2026
  const line = newLines[i];
1850
2027
  let truncatedLine = line;
1851
2028
  const isImage = TERMINAL.isImageLine(line);
1852
- if (!isImage && visibleWidth(line) > width) {
2029
+ const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
2030
+ if (!isImage && lineWidth > width) {
1853
2031
  if (debugRedraw) {
1854
2032
  const debugData = [
1855
2033
  `[TUI Truncate] ${new Date().toISOString()}`,
1856
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
2034
+ `Line ${i} truncated: ${lineWidth} > ${width}`,
1857
2035
  `Content preview: ${line.slice(0, 100)}...`,
1858
2036
  "",
1859
2037
  ].join("\n");
1860
2038
  try {
1861
- fs.appendFileSync(getDebugLogPath(), debugData);
2039
+ this.#appendDebugRedrawLog(debugData);
1862
2040
  } catch {
1863
2041
  // Ignore write errors - truncation should still work
1864
2042
  }