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