@gajae-code/tui 0.8.2 → 0.9.1

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";
@@ -135,37 +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);
140
142
  }
141
143
 
142
144
  const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
143
145
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
146
+ const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
144
147
 
145
148
  function envIsEnabled(value: string | undefined): boolean {
146
149
  const normalized = value?.trim().toLowerCase();
147
150
  return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
148
151
  }
149
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
+
150
158
  function termLooksMultiplexed(value: string | undefined): boolean {
151
159
  const term = value?.trim().toLowerCase() ?? "";
152
160
  return term.startsWith("tmux") || term.startsWith("screen");
153
161
  }
154
162
 
163
+ function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
164
+ return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
165
+ }
166
+
155
167
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
156
- function isMultiplexerSession(): boolean {
168
+ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
157
169
  return Boolean(
158
- envIsEnabled(Bun.env.TMUX) ||
159
- envIsEnabled(Bun.env.TMUX_PANE) ||
160
- envIsEnabled(Bun.env.STY) ||
161
- envIsEnabled(Bun.env.ZELLIJ) ||
162
- envIsEnabled(Bun.env[GJC_TMUX_LAUNCHED_ENV]) ||
163
- termLooksMultiplexed(Bun.env.TERM),
170
+ envIsEnabled(env.TMUX) ||
171
+ envIsEnabled(env.TMUX_PANE) ||
172
+ envIsEnabled(env.STY) ||
173
+ envIsEnabled(env.ZELLIJ) ||
174
+ envIsEnabled(env[GJC_TMUX_LAUNCHED_ENV]) ||
175
+ termLooksMultiplexed(env.TERM),
164
176
  );
165
177
  }
166
178
 
167
- function useLegacyMultiplexerFullRender(): boolean {
168
- return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
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
+ );
207
+ }
208
+
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);
169
217
  }
170
218
 
171
219
  /**
@@ -323,6 +371,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
323
371
  type LineNormalizationCacheEntry = {
324
372
  normalized: string;
325
373
  terminated: string;
374
+ width: number | undefined;
375
+ };
376
+
377
+ type TuiRenderCounterSnapshot = {
378
+ debugRedrawEnvReads: number;
379
+ debugRedrawAppendWrites: number;
380
+ differentialGuardVisibleWidthCalls: number;
326
381
  };
327
382
 
328
383
  /**
@@ -339,6 +394,7 @@ export class TUI extends Container {
339
394
  */
340
395
  #previousRaw: string[] = [];
341
396
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
397
+ #lineEmitWidthCache = new Map<string, number>();
342
398
  #lineTruncationCache = new Map<string, string>();
343
399
  #lineNormalizationCacheLimit = 0;
344
400
  #lineTruncationCacheLimit = 0;
@@ -369,20 +425,58 @@ export class TUI extends Container {
369
425
  #sixelProbeTimeout?: NodeJS.Timeout;
370
426
  #sixelProbeUnsubscribe?: () => void;
371
427
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
428
+ #debugRedraw = TUI.#readDebugRedrawFlag();
372
429
  // macOS: steady-block cursor anchors CJK IME overlays; disable with GJC_TUI_IME_CURSOR=0.
373
430
  readonly #useImeBlockCursor = $flag("GJC_TUI_IME_CURSOR", process.platform === "darwin");
374
431
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
375
432
  #imeCursorActive = false;
376
433
  #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");
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);
380
438
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
381
439
  #fullRedrawCount = 0;
382
440
  #stopped = false;
383
441
  #terminalUnavailable = false;
384
442
  #bottomPinnedComponent: Component | null = null;
385
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
+
386
480
  // Overlay stack for modal components rendered on top of base content
387
481
  overlayStack: {
388
482
  component: Component;
@@ -398,6 +492,18 @@ export class TUI extends Container {
398
492
  this.#showHardwareCursor = showHardwareCursor;
399
493
  }
400
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();
401
507
  }
402
508
 
403
509
  get fullRedraws(): number {
@@ -458,19 +564,13 @@ export class TUI extends Container {
458
564
  const pageStep = Math.max(1, height - 1);
459
565
  const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
460
566
 
461
- if (targetViewportTop >= maxViewportTop) {
462
- this.#manualViewportTop = undefined;
463
- } else {
464
- this.#manualViewportTop = targetViewportTop;
465
- }
466
-
467
- const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
567
+ this.#manualViewportTop = targetViewportTop;
468
568
  return this.#repaintViewportFromLines(
469
569
  this.#previousLines,
470
570
  width,
471
571
  height,
472
572
  targetViewportTop,
473
- cursorPos,
573
+ null,
474
574
  "manual viewport scroll",
475
575
  );
476
576
  }
@@ -829,24 +929,26 @@ export class TUI extends Container {
829
929
  this.#previousRaw = [];
830
930
  this.#lineNormalizationCache.clear();
831
931
  this.#lineTruncationCache.clear();
932
+ this.#lineEmitWidthCache.clear();
832
933
  this.#previousWidth = 0;
833
934
  this.#previousHeight = 0;
834
935
  }
835
936
 
836
937
  /**
837
- * Multiplexer-aware resize render request.
938
+ * Viewport-repaint-aware resize render request.
838
939
  *
839
940
  * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
840
941
  * to -1, which makes `#doRender` treat the frame as a width change and fall into the
841
942
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
842
943
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
843
944
  * 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.
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.
847
949
  */
848
950
  requestResizeRender(): void {
849
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
951
+ this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
850
952
  }
851
953
 
852
954
  requestRender(force = false, source = "unknown"): void {
@@ -856,20 +958,24 @@ export class TUI extends Container {
856
958
  }
857
959
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
858
960
  if (force) {
961
+ const preserveViewportCursor = useViewportRepaintPath(this.terminal);
859
962
  // A forced full redraw supersedes any queued input-priority render.
860
963
  this.#inputRenderPending = false;
861
964
  this.#previousLines = [];
862
965
  this.#previousRaw = [];
863
966
  this.#lineNormalizationCache.clear();
864
967
  this.#lineTruncationCache.clear();
968
+ this.#lineEmitWidthCache.clear();
865
969
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
866
970
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
867
971
  this.#lineNormalizationCacheLimit = 0;
868
972
  this.#lineTruncationCacheLimit = 0;
869
- this.#cursorRow = 0;
870
- this.#hardwareCursorRow = 0;
871
- this.#viewportTopRow = 0;
872
- this.#maxLinesRendered = 0;
973
+ if (!preserveViewportCursor) {
974
+ this.#cursorRow = 0;
975
+ this.#hardwareCursorRow = 0;
976
+ this.#viewportTopRow = 0;
977
+ this.#maxLinesRendered = 0;
978
+ }
873
979
  if (this.#renderTimer) {
874
980
  clearTimeout(this.#renderTimer);
875
981
  this.#renderTimer = undefined;
@@ -1335,24 +1441,9 @@ export class TUI extends Container {
1335
1441
  if (cached !== undefined) return cached;
1336
1442
  const normalized = normalizeTerminalOutput(line);
1337
1443
  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;
1444
+ const entry = { normalized, terminated, width: undefined };
1445
+ this.#lineNormalizationCache.set(line, entry);
1446
+ return entry;
1356
1447
  }
1357
1448
 
1358
1449
  #trimLineCachesForRender(lineCount: number): void {
@@ -1362,6 +1453,8 @@ export class TUI extends Container {
1362
1453
  while (this.#lineNormalizationCache.size > limit) {
1363
1454
  const key = this.#lineNormalizationCache.keys().next().value;
1364
1455
  if (key === undefined) break;
1456
+ const entry = this.#lineNormalizationCache.get(key);
1457
+ if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
1365
1458
  this.#lineNormalizationCache.delete(key);
1366
1459
  }
1367
1460
  while (this.#lineTruncationCache.size > limit) {
@@ -1369,6 +1462,11 @@ export class TUI extends Container {
1369
1462
  if (key === undefined) break;
1370
1463
  this.#lineTruncationCache.delete(key);
1371
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
+ }
1372
1470
  }
1373
1471
 
1374
1472
  getLineRenderCacheStats(): {
@@ -1385,17 +1483,66 @@ export class TUI extends Container {
1385
1483
  };
1386
1484
  }
1387
1485
 
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);
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;
1393
1542
  }
1394
1543
 
1395
1544
  #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1396
- for (let i = 0; i < lines.length; i++) {
1397
- lines[i] = this.#normalizeLineForEmit(lines[i], width);
1398
- }
1545
+ this.#normalizeLinesForEmit(lines, width);
1399
1546
  this.#trimLineCachesForRender(lines.length);
1400
1547
  return lines;
1401
1548
  }
@@ -1449,7 +1596,7 @@ export class TUI extends Container {
1449
1596
  if (lineIndex >= lines.length) continue;
1450
1597
  const line = lines[lineIndex];
1451
1598
  const isImage = TERMINAL.isImageLine(line);
1452
- if (!isImage && visibleWidth(line) > width) {
1599
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1453
1600
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1454
1601
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1455
1602
  buffer += truncatedLine;
@@ -1471,10 +1618,9 @@ export class TUI extends Container {
1471
1618
  buffer += "\x1b[?2026l";
1472
1619
  if (!this.#writeTerminal(buffer)) return false;
1473
1620
 
1474
- if ($flag("PI_DEBUG_REDRAW")) {
1475
- const logPath = getDebugLogPath();
1621
+ if (this.#debugRedraw) {
1476
1622
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1477
- fs.appendFileSync(logPath, msg);
1623
+ this.#appendDebugRedrawLog(msg);
1478
1624
  }
1479
1625
 
1480
1626
  this.#cursorRow = Math.max(0, lines.length - 1);
@@ -1551,8 +1697,9 @@ export class TUI extends Container {
1551
1697
  if (stable) {
1552
1698
  const windowed = this.#previousLines.slice(0, winTop);
1553
1699
  for (let i = winTop; i < total; i++) {
1554
- windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
1700
+ windowed.push(rawLines[i]);
1555
1701
  }
1702
+ this.#normalizeLinesForEmit(windowed, width, winTop);
1556
1703
  this.#trimLineCachesForRender(total);
1557
1704
  newLines = windowed;
1558
1705
  diffStart = winTop;
@@ -1576,9 +1723,8 @@ export class TUI extends Container {
1576
1723
  if (this.#manualViewportTop !== undefined) {
1577
1724
  const maxViewportTop = Math.max(0, newLines.length - height);
1578
1725
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
1579
- const followingLive = nextViewportTop >= maxViewportTop;
1580
- this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
1581
- const repaintCursorPos = followingLive ? cursorPos : null;
1726
+ this.#manualViewportTop = nextViewportTop;
1727
+ const repaintCursorPos = null;
1582
1728
  if (
1583
1729
  this.#repaintViewportFromLines(
1584
1730
  newLines,
@@ -1600,8 +1746,10 @@ export class TUI extends Container {
1600
1746
  this.#fullRedrawCount += 1;
1601
1747
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1602
1748
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1603
- // Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
1604
- 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";
1605
1753
  for (let i = 0; i < newLines.length; i++) {
1606
1754
  if (i > 0) buffer += "\r\n";
1607
1755
  // Lines were pre-terminated/normalized by #applyLineResets; image
@@ -1626,7 +1774,7 @@ export class TUI extends Container {
1626
1774
  this.#previousHeight = height;
1627
1775
  };
1628
1776
 
1629
- const multiplexerViewportRepaint = (reason: string): void => {
1777
+ const viewportRepaint = (reason: string): void => {
1630
1778
  this.#fullRedrawCount += 1;
1631
1779
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1632
1780
  const nextViewportTop = Math.max(0, newLines.length - height);
@@ -1643,7 +1791,7 @@ export class TUI extends Container {
1643
1791
  if (lineIndex >= newLines.length) continue;
1644
1792
  const line = newLines[lineIndex];
1645
1793
  const isImage = TERMINAL.isImageLine(line);
1646
- if (!isImage && visibleWidth(line) > width) {
1794
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1647
1795
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1648
1796
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1649
1797
  buffer += truncatedLine;
@@ -1665,15 +1813,14 @@ export class TUI extends Container {
1665
1813
  buffer += "\x1b[?2026l";
1666
1814
  if (!this.#writeTerminal(buffer)) return;
1667
1815
 
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);
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);
1672
1819
  }
1673
- // In multiplexers this deliberately prioritizes the live viewport over
1820
+ // Viewport repaint deliberately prioritizes the live viewport over
1674
1821
  // historical scrollback repair. After offscreen changes, #previousLines
1675
1822
  // tracks the desired logical transcript, not every byte emitted into the
1676
- // multiplexer scrollback.
1823
+ // terminal scrollback.
1677
1824
  this.#cursorRow = Math.max(0, newLines.length - 1);
1678
1825
  this.#maxLinesRendered = newLines.length;
1679
1826
  this.#viewportTopRow = nextViewportTop;
@@ -1682,12 +1829,11 @@ export class TUI extends Container {
1682
1829
  this.#previousHeight = height;
1683
1830
  };
1684
1831
 
1685
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1832
+ const debugRedraw = this.#debugRedraw;
1686
1833
  const logRedraw = (reason: string): void => {
1687
1834
  if (!debugRedraw) return;
1688
- const logPath = getDebugLogPath();
1689
1835
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1690
- fs.appendFileSync(logPath, msg);
1836
+ this.#appendDebugRedrawLog(msg);
1691
1837
  };
1692
1838
 
1693
1839
  // First render - just output everything without clearing (assumes clean screen)
@@ -1700,13 +1846,12 @@ export class TUI extends Container {
1700
1846
  // Width changes always need a full re-render because wrapping changes.
1701
1847
  if (widthChanged) {
1702
1848
  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})`);
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})`);
1710
1855
  } else {
1711
1856
  fullRender(true, "terminal width changed");
1712
1857
  }
@@ -1717,8 +1862,8 @@ export class TUI extends Container {
1717
1862
  // but Termux changes height when the software keyboard shows or hides.
1718
1863
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1719
1864
  if (heightChanged) {
1720
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1721
- multiplexerViewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1865
+ if (useViewportRepaintPath(this.terminal)) {
1866
+ viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1722
1867
  return;
1723
1868
  }
1724
1869
  if (!isTermuxSession() && !isMultiplexerSession()) {
@@ -1733,7 +1878,11 @@ export class TUI extends Container {
1733
1878
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1734
1879
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1735
1880
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1736
- 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
+ }
1737
1886
  return;
1738
1887
  }
1739
1888
 
@@ -1771,6 +1920,11 @@ export class TUI extends Container {
1771
1920
  return;
1772
1921
  }
1773
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
+ }
1774
1928
  // All changes are in deleted lines (nothing to render, just clear)
1775
1929
  if (firstChanged >= newLines.length) {
1776
1930
  if (this.#previousLines.length > newLines.length) {
@@ -1785,8 +1939,8 @@ export class TUI extends Container {
1785
1939
  const extraLines = this.#previousLines.length - newLines.length;
1786
1940
  if (extraLines > height) {
1787
1941
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1788
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1789
- multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1942
+ if (useViewportRepaintPath(this.terminal)) {
1943
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1790
1944
  } else {
1791
1945
  fullRender(true, "extraLines > height");
1792
1946
  }
@@ -1819,16 +1973,19 @@ export class TUI extends Container {
1819
1973
  return;
1820
1974
  }
1821
1975
 
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.
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.
1825
1982
  if (firstChanged < prevViewportTop) {
1826
1983
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1827
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1828
- multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1829
- } else {
1830
- fullRender(true, "firstChanged < viewportTop");
1984
+ if (useViewportRepaintPath(this.terminal)) {
1985
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1986
+ return;
1831
1987
  }
1988
+ fullRender(true, "firstChanged < viewportTop");
1832
1989
  return;
1833
1990
  }
1834
1991
 
@@ -1869,16 +2026,17 @@ export class TUI extends Container {
1869
2026
  const line = newLines[i];
1870
2027
  let truncatedLine = line;
1871
2028
  const isImage = TERMINAL.isImageLine(line);
1872
- if (!isImage && visibleWidth(line) > width) {
2029
+ const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
2030
+ if (!isImage && lineWidth > width) {
1873
2031
  if (debugRedraw) {
1874
2032
  const debugData = [
1875
2033
  `[TUI Truncate] ${new Date().toISOString()}`,
1876
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
2034
+ `Line ${i} truncated: ${lineWidth} > ${width}`,
1877
2035
  `Content preview: ${line.slice(0, 100)}...`,
1878
2036
  "",
1879
2037
  ].join("\n");
1880
2038
  try {
1881
- fs.appendFileSync(getDebugLogPath(), debugData);
2039
+ this.#appendDebugRedrawLog(debugData);
1882
2040
  } catch {
1883
2041
  // Ignore write errors - truncation should still work
1884
2042
  }