@sayknow-cli/tui 0.3.6 → 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.
Files changed (43) hide show
  1. package/dist/types/animation-scheduler.d.ts +13 -0
  2. package/dist/types/autocomplete.d.ts +83 -0
  3. package/dist/types/bracketed-paste.d.ts +26 -0
  4. package/dist/types/components/box.d.ts +20 -0
  5. package/dist/types/components/cancellable-loader.d.ts +21 -0
  6. package/dist/types/components/editor.d.ts +126 -0
  7. package/dist/types/components/image.d.ts +16 -0
  8. package/dist/types/components/input.d.ts +16 -0
  9. package/dist/types/components/loader.d.ts +23 -0
  10. package/dist/types/components/markdown.d.ts +77 -0
  11. package/dist/types/components/select-list.d.ts +46 -0
  12. package/dist/types/components/settings-list.d.ts +39 -0
  13. package/dist/types/components/spacer.d.ts +11 -0
  14. package/dist/types/components/tab-bar.d.ts +56 -0
  15. package/dist/types/components/text.d.ts +13 -0
  16. package/dist/types/components/truncated-text.d.ts +10 -0
  17. package/dist/types/editor-component.d.ts +36 -0
  18. package/dist/types/fuzzy.d.ts +15 -0
  19. package/dist/types/index.d.ts +27 -0
  20. package/dist/types/keybindings.d.ts +201 -0
  21. package/dist/types/keys.d.ts +208 -0
  22. package/dist/types/kill-ring.d.ts +27 -0
  23. package/dist/types/metrics.d.ts +85 -0
  24. package/dist/types/stdin-buffer.d.ts +50 -0
  25. package/dist/types/symbols.d.ts +23 -0
  26. package/dist/types/terminal-capabilities.d.ts +75 -0
  27. package/dist/types/terminal.d.ts +88 -0
  28. package/dist/types/ttyid.d.ts +9 -0
  29. package/dist/types/tui.d.ts +206 -0
  30. package/dist/types/utils.d.ts +87 -0
  31. package/package.json +10 -9
  32. package/src/animation-scheduler.ts +99 -0
  33. package/src/autocomplete.ts +119 -96
  34. package/src/components/editor.ts +310 -128
  35. package/src/components/input.ts +2 -1
  36. package/src/components/loader.ts +36 -37
  37. package/src/components/markdown.ts +79 -2
  38. package/src/components/select-list.ts +8 -1
  39. package/src/index.ts +1 -0
  40. package/src/stdin-buffer.ts +89 -11
  41. package/src/terminal.ts +44 -8
  42. package/src/tui.ts +362 -64
  43. package/src/utils.ts +77 -11
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;
@@ -341,26 +383,66 @@ export class TUI extends Container {
341
383
  #cursorRow = 0; // Logical cursor row (end of rendered content)
342
384
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
343
385
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
386
+ #manualViewportTop: number | undefined;
387
+ #lastCursorPosition: { row: number; col: number } | null = null;
344
388
  #sixelProbePendingDa = false;
345
389
  #sixelProbePendingGraphics = false;
346
390
  #sixelProbeBuffer = "";
347
391
  #sixelProbeTimeout?: NodeJS.Timeout;
348
392
  #sixelProbeUnsubscribe?: () => void;
349
393
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
394
+ #debugRedraw = TUI.#readDebugRedrawFlag();
350
395
  // macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
351
396
  readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
352
397
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
353
398
  #imeCursorActive = false;
354
399
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
355
- // Opt-in: reuse the previous normalized off-screen prefix and only normalize/diff the
356
- // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical.
357
- #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);
358
404
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
359
405
  #fullRedrawCount = 0;
360
406
  #stopped = false;
361
407
  #terminalUnavailable = false;
362
408
  #bottomPinnedComponent: Component | null = null;
363
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
+
364
446
  // Overlay stack for modal components rendered on top of base content
365
447
  overlayStack: {
366
448
  component: Component;
@@ -376,6 +458,18 @@ export class TUI extends Container {
376
458
  this.#showHardwareCursor = showHardwareCursor;
377
459
  }
378
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();
379
473
  }
380
474
 
381
475
  get fullRedraws(): number {
@@ -427,6 +521,47 @@ export class TUI extends Container {
427
521
  this.#bottomPinnedComponent = component;
428
522
  this.requestRender();
429
523
  }
524
+ scrollViewportPages(direction: -1 | 1): boolean {
525
+ const height = this.terminal.rows;
526
+ const width = this.terminal.columns;
527
+ if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
528
+ const maxViewportTop = Math.max(0, this.#previousLines.length - height);
529
+ const currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
530
+ const pageStep = Math.max(1, height - 1);
531
+ const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
532
+
533
+ if (targetViewportTop >= maxViewportTop) {
534
+ this.#manualViewportTop = undefined;
535
+ } else {
536
+ this.#manualViewportTop = targetViewportTop;
537
+ }
538
+
539
+ const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
540
+ return this.#repaintViewportFromLines(
541
+ this.#previousLines,
542
+ width,
543
+ height,
544
+ targetViewportTop,
545
+ cursorPos,
546
+ "manual viewport scroll",
547
+ );
548
+ }
549
+
550
+ followLiveViewport(): boolean {
551
+ if (this.#manualViewportTop === undefined) return false;
552
+ const height = this.terminal.rows;
553
+ const width = this.terminal.columns;
554
+ const liveViewportTop = Math.max(0, this.#previousLines.length - height);
555
+ this.#manualViewportTop = undefined;
556
+ return this.#repaintViewportFromLines(
557
+ this.#previousLines,
558
+ width,
559
+ height,
560
+ liveViewportTop,
561
+ this.#lastCursorPosition,
562
+ "manual viewport follow live",
563
+ );
564
+ }
430
565
 
431
566
  /**
432
567
  * Show an overlay component with configurable positioning and sizing.
@@ -526,7 +661,7 @@ export class TUI extends Container {
526
661
  data => this.#handleInput(data),
527
662
  () => {
528
663
  this.invalidate();
529
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
664
+ this.requestResizeRender();
530
665
  },
531
666
  );
532
667
  this.#hideCursor();
@@ -766,10 +901,28 @@ export class TUI extends Container {
766
901
  this.#previousRaw = [];
767
902
  this.#lineNormalizationCache.clear();
768
903
  this.#lineTruncationCache.clear();
904
+ this.#lineEmitWidthCache.clear();
769
905
  this.#previousWidth = 0;
770
906
  this.#previousHeight = 0;
771
907
  }
772
908
 
909
+ /**
910
+ * Viewport-repaint-aware resize render request.
911
+ *
912
+ * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
913
+ * to -1, which makes `#doRender` treat the frame as a width change and fall into the
914
+ * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
915
+ * `3J` escape (users navigate scrollback history), so replaying every transcript line
916
+ * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
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.
921
+ */
922
+ requestResizeRender(): void {
923
+ this.requestRender(!useViewportRepaintPath() && !isTermuxSession(), "resize");
924
+ }
925
+
773
926
  requestRender(force = false, source = "unknown"): void {
774
927
  if (!this.terminalAvailable) {
775
928
  this.#markTerminalUnavailable();
@@ -777,20 +930,24 @@ export class TUI extends Container {
777
930
  }
778
931
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
779
932
  if (force) {
933
+ const preserveViewportCursor = useViewportRepaintPath();
780
934
  // A forced full redraw supersedes any queued input-priority render.
781
935
  this.#inputRenderPending = false;
782
936
  this.#previousLines = [];
783
937
  this.#previousRaw = [];
784
938
  this.#lineNormalizationCache.clear();
785
939
  this.#lineTruncationCache.clear();
940
+ this.#lineEmitWidthCache.clear();
786
941
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
787
942
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
788
943
  this.#lineNormalizationCacheLimit = 0;
789
944
  this.#lineTruncationCacheLimit = 0;
790
- this.#cursorRow = 0;
791
- this.#hardwareCursorRow = 0;
792
- this.#viewportTopRow = 0;
793
- this.#maxLinesRendered = 0;
945
+ if (!preserveViewportCursor) {
946
+ this.#cursorRow = 0;
947
+ this.#hardwareCursorRow = 0;
948
+ this.#viewportTopRow = 0;
949
+ this.#maxLinesRendered = 0;
950
+ }
794
951
  if (this.#renderTimer) {
795
952
  clearTimeout(this.#renderTimer);
796
953
  this.#renderTimer = undefined;
@@ -1256,24 +1413,9 @@ export class TUI extends Container {
1256
1413
  if (cached !== undefined) return cached;
1257
1414
  const normalized = normalizeTerminalOutput(line);
1258
1415
  const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1259
- this.#lineNormalizationCache.set(line, { normalized, terminated });
1260
- return { normalized, terminated };
1261
- }
1262
-
1263
- #lineFitsWidth(normalizedLine: string, width: number): boolean {
1264
- return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
1265
- ? true
1266
- : visibleWidth(normalizedLine) <= width;
1267
- }
1268
-
1269
- #truncateNormalizedLine(normalizedLine: string, width: number): string {
1270
- const key = `${width}\0${normalizedLine}`;
1271
- const cached = this.#lineTruncationCache.get(key);
1272
- if (cached !== undefined) return cached;
1273
- const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
1274
- const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1275
- this.#lineTruncationCache.set(key, terminated);
1276
- return terminated;
1416
+ const entry = { normalized, terminated, width: undefined };
1417
+ this.#lineNormalizationCache.set(line, entry);
1418
+ return entry;
1277
1419
  }
1278
1420
 
1279
1421
  #trimLineCachesForRender(lineCount: number): void {
@@ -1283,6 +1425,8 @@ export class TUI extends Container {
1283
1425
  while (this.#lineNormalizationCache.size > limit) {
1284
1426
  const key = this.#lineNormalizationCache.keys().next().value;
1285
1427
  if (key === undefined) break;
1428
+ const entry = this.#lineNormalizationCache.get(key);
1429
+ if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
1286
1430
  this.#lineNormalizationCache.delete(key);
1287
1431
  }
1288
1432
  while (this.#lineTruncationCache.size > limit) {
@@ -1290,6 +1434,11 @@ export class TUI extends Container {
1290
1434
  if (key === undefined) break;
1291
1435
  this.#lineTruncationCache.delete(key);
1292
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
+ }
1293
1442
  }
1294
1443
 
1295
1444
  getLineRenderCacheStats(): {
@@ -1306,17 +1455,66 @@ export class TUI extends Container {
1306
1455
  };
1307
1456
  }
1308
1457
 
1309
- /** Normalize + width-fit a single line for emission (image lines pass through). */
1310
- #normalizeLineForEmit(line: string, width: number): string {
1311
- if (TERMINAL.isImageLine(line)) return line;
1312
- const { normalized, terminated } = this.#normalizeLineForRender(line);
1313
- 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;
1314
1514
  }
1315
1515
 
1316
1516
  #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1317
- for (let i = 0; i < lines.length; i++) {
1318
- lines[i] = this.#normalizeLineForEmit(lines[i], width);
1319
- }
1517
+ this.#normalizeLinesForEmit(lines, width);
1320
1518
  this.#trimLineCachesForRender(lines.length);
1321
1519
  return lines;
1322
1520
  }
@@ -1345,6 +1543,63 @@ export class TUI extends Container {
1345
1543
  padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
1346
1544
  return padded;
1347
1545
  }
1546
+ #repaintViewportFromLines(
1547
+ lines: string[],
1548
+ width: number,
1549
+ height: number,
1550
+ viewportTop: number,
1551
+ cursorPos: { row: number; col: number } | null,
1552
+ reason: string,
1553
+ ): boolean {
1554
+ if (height <= 0 || width <= 0) return false;
1555
+ const maxViewportTop = Math.max(0, lines.length - height);
1556
+ const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
1557
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
1558
+ let buffer = "\x1b[?2026h";
1559
+ if (currentScreenRow > 0) {
1560
+ buffer += `\x1b[${currentScreenRow}A`;
1561
+ }
1562
+ buffer += "\r";
1563
+
1564
+ for (let screenRow = 0; screenRow < height; screenRow++) {
1565
+ if (screenRow > 0) buffer += "\r\n";
1566
+ buffer += "\x1b[2K";
1567
+ const lineIndex = nextViewportTop + screenRow;
1568
+ if (lineIndex >= lines.length) continue;
1569
+ const line = lines[lineIndex];
1570
+ const isImage = TERMINAL.isImageLine(line);
1571
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1572
+ let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1573
+ truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1574
+ buffer += truncatedLine;
1575
+ } else {
1576
+ buffer += line;
1577
+ }
1578
+ }
1579
+
1580
+ const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
1581
+ let cursorSeq = "\x1b[?25l";
1582
+ let cursorToRow = finalPhysicalRow;
1583
+ if (cursorPos && cursorPos.row >= nextViewportTop && cursorPos.row < nextViewportTop + height) {
1584
+ const cursor = this.#cursorControlSequence(cursorPos, lines.length, finalPhysicalRow);
1585
+ cursorSeq = cursor.seq;
1586
+ cursorToRow = cursor.toRow;
1587
+ }
1588
+ this.#hardwareCursorRow = cursorToRow;
1589
+ buffer += cursorSeq;
1590
+ buffer += "\x1b[?2026l";
1591
+ if (!this.#writeTerminal(buffer)) return false;
1592
+
1593
+ if (this.#debugRedraw) {
1594
+ const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1595
+ this.#appendDebugRedrawLog(msg);
1596
+ }
1597
+
1598
+ this.#cursorRow = Math.max(0, lines.length - 1);
1599
+ this.#maxLinesRendered = lines.length;
1600
+ this.#viewportTopRow = nextViewportTop;
1601
+ return true;
1602
+ }
1348
1603
 
1349
1604
  #doRender(): void {
1350
1605
  if (this.#stopped || !this.terminalAvailable) return;
@@ -1375,6 +1630,7 @@ export class TUI extends Container {
1375
1630
 
1376
1631
  // Extract cursor position (marker must be found before diff comparison)
1377
1632
  const cursorPos = this.#extractCursorPosition(newLines, height);
1633
+ this.#lastCursorPosition = cursorPos;
1378
1634
 
1379
1635
  // Terminate every non-image line so #previousLines mirrors emitted bytes
1380
1636
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
@@ -1413,8 +1669,9 @@ export class TUI extends Container {
1413
1669
  if (stable) {
1414
1670
  const windowed = this.#previousLines.slice(0, winTop);
1415
1671
  for (let i = winTop; i < total; i++) {
1416
- windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
1672
+ windowed.push(rawLines[i]);
1417
1673
  }
1674
+ this.#normalizeLinesForEmit(windowed, width, winTop);
1418
1675
  this.#trimLineCachesForRender(total);
1419
1676
  newLines = windowed;
1420
1677
  diffStart = winTop;
@@ -1435,6 +1692,28 @@ export class TUI extends Container {
1435
1692
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
1436
1693
  }
1437
1694
 
1695
+ if (this.#manualViewportTop !== undefined) {
1696
+ const maxViewportTop = Math.max(0, newLines.length - height);
1697
+ const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
1698
+ const followingLive = nextViewportTop >= maxViewportTop;
1699
+ this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
1700
+ const repaintCursorPos = followingLive ? cursorPos : null;
1701
+ if (
1702
+ this.#repaintViewportFromLines(
1703
+ newLines,
1704
+ width,
1705
+ height,
1706
+ nextViewportTop,
1707
+ repaintCursorPos,
1708
+ "manual viewport render",
1709
+ )
1710
+ ) {
1711
+ this.#previousLines = newLines;
1712
+ this.#previousWidth = width;
1713
+ this.#previousHeight = height;
1714
+ }
1715
+ return;
1716
+ }
1438
1717
  // Helper to clear scrollback and viewport and render all new lines
1439
1718
  const fullRender = (clear: boolean, reason = "full render"): void => {
1440
1719
  this.#fullRedrawCount += 1;
@@ -1466,7 +1745,7 @@ export class TUI extends Container {
1466
1745
  this.#previousHeight = height;
1467
1746
  };
1468
1747
 
1469
- const multiplexerViewportRepaint = (reason: string): void => {
1748
+ const viewportRepaint = (reason: string): void => {
1470
1749
  this.#fullRedrawCount += 1;
1471
1750
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1472
1751
  const nextViewportTop = Math.max(0, newLines.length - height);
@@ -1483,7 +1762,7 @@ export class TUI extends Container {
1483
1762
  if (lineIndex >= newLines.length) continue;
1484
1763
  const line = newLines[lineIndex];
1485
1764
  const isImage = TERMINAL.isImageLine(line);
1486
- if (!isImage && visibleWidth(line) > width) {
1765
+ if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
1487
1766
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1488
1767
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1489
1768
  buffer += truncatedLine;
@@ -1505,15 +1784,14 @@ export class TUI extends Container {
1505
1784
  buffer += "\x1b[?2026l";
1506
1785
  if (!this.#writeTerminal(buffer)) return;
1507
1786
 
1508
- if ($flag("PI_DEBUG_REDRAW")) {
1509
- const logPath = getDebugLogPath();
1510
- const msg = `[${new Date().toISOString()}] multiplexerViewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1511
- 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);
1512
1790
  }
1513
- // In multiplexers this deliberately prioritizes the live viewport over
1791
+ // Viewport repaint deliberately prioritizes the live viewport over
1514
1792
  // historical scrollback repair. After offscreen changes, #previousLines
1515
1793
  // tracks the desired logical transcript, not every byte emitted into the
1516
- // multiplexer scrollback.
1794
+ // terminal scrollback.
1517
1795
  this.#cursorRow = Math.max(0, newLines.length - 1);
1518
1796
  this.#maxLinesRendered = newLines.length;
1519
1797
  this.#viewportTopRow = nextViewportTop;
@@ -1522,12 +1800,11 @@ export class TUI extends Container {
1522
1800
  this.#previousHeight = height;
1523
1801
  };
1524
1802
 
1525
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1803
+ const debugRedraw = this.#debugRedraw;
1526
1804
  const logRedraw = (reason: string): void => {
1527
1805
  if (!debugRedraw) return;
1528
- const logPath = getDebugLogPath();
1529
1806
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1530
- fs.appendFileSync(logPath, msg);
1807
+ this.#appendDebugRedrawLog(msg);
1531
1808
  };
1532
1809
 
1533
1810
  // First render - just output everything without clearing (assumes clean screen)
@@ -1540,7 +1817,15 @@ export class TUI extends Container {
1540
1817
  // Width changes always need a full re-render because wrapping changes.
1541
1818
  if (widthChanged) {
1542
1819
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1543
- fullRender(true, "terminal width changed");
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})`);
1826
+ } else {
1827
+ fullRender(true, "terminal width changed");
1828
+ }
1544
1829
  return;
1545
1830
  }
1546
1831
 
@@ -1548,8 +1833,8 @@ export class TUI extends Container {
1548
1833
  // but Termux changes height when the software keyboard shows or hides.
1549
1834
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1550
1835
  if (heightChanged) {
1551
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1552
- multiplexerViewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1836
+ if (useViewportRepaintPath()) {
1837
+ viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1553
1838
  return;
1554
1839
  }
1555
1840
  if (!isTermuxSession() && !isMultiplexerSession()) {
@@ -1564,7 +1849,11 @@ export class TUI extends Container {
1564
1849
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1565
1850
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1566
1851
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1567
- fullRender(true, "clearOnShrink");
1852
+ if (useViewportRepaintPath()) {
1853
+ viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1854
+ } else {
1855
+ fullRender(true, "clearOnShrink");
1856
+ }
1568
1857
  return;
1569
1858
  }
1570
1859
 
@@ -1602,6 +1891,11 @@ export class TUI extends Container {
1602
1891
  return;
1603
1892
  }
1604
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
+ }
1605
1899
  // All changes are in deleted lines (nothing to render, just clear)
1606
1900
  if (firstChanged >= newLines.length) {
1607
1901
  if (this.#previousLines.length > newLines.length) {
@@ -1616,8 +1910,8 @@ export class TUI extends Container {
1616
1910
  const extraLines = this.#previousLines.length - newLines.length;
1617
1911
  if (extraLines > height) {
1618
1912
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1619
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1620
- multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1913
+ if (useViewportRepaintPath()) {
1914
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1621
1915
  } else {
1622
1916
  fullRender(true, "extraLines > height");
1623
1917
  }
@@ -1650,16 +1944,19 @@ export class TUI extends Container {
1650
1944
  return;
1651
1945
  }
1652
1946
 
1653
- // Differential rendering can only touch what was actually visible.
1654
- // Any change above the previous viewport requires a full redraw so terminal
1655
- // 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.
1656
1953
  if (firstChanged < prevViewportTop) {
1657
1954
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1658
- if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1659
- multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1660
- } else {
1661
- fullRender(true, "firstChanged < viewportTop");
1955
+ if (useViewportRepaintPath()) {
1956
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1957
+ return;
1662
1958
  }
1959
+ fullRender(true, "firstChanged < viewportTop");
1663
1960
  return;
1664
1961
  }
1665
1962
 
@@ -1700,16 +1997,17 @@ export class TUI extends Container {
1700
1997
  const line = newLines[i];
1701
1998
  let truncatedLine = line;
1702
1999
  const isImage = TERMINAL.isImageLine(line);
1703
- if (!isImage && visibleWidth(line) > width) {
2000
+ const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
2001
+ if (!isImage && lineWidth > width) {
1704
2002
  if (debugRedraw) {
1705
2003
  const debugData = [
1706
2004
  `[TUI Truncate] ${new Date().toISOString()}`,
1707
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
2005
+ `Line ${i} truncated: ${lineWidth} > ${width}`,
1708
2006
  `Content preview: ${line.slice(0, 100)}...`,
1709
2007
  "",
1710
2008
  ].join("\n");
1711
2009
  try {
1712
- fs.appendFileSync(getDebugLogPath(), debugData);
2010
+ this.#appendDebugRedrawLog(debugData);
1713
2011
  } catch {
1714
2012
  // Ignore write errors - truncation should still work
1715
2013
  }