@sayknow-cli/tui 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/animation-scheduler.d.ts +13 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/components/editor.d.ts +11 -2
- 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 +4 -4
- package/src/animation-scheduler.ts +99 -0
- package/src/autocomplete.ts +119 -96
- package/src/components/editor.ts +283 -146
- 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 +227 -78
- 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(
|
|
167
|
+
return Boolean(
|
|
168
|
+
envIsEnabled(Bun.env.TMUX) ||
|
|
169
|
+
envIsEnabled(Bun.env.TMUX_PANE) ||
|
|
170
|
+
envIsEnabled(Bun.env.STY) ||
|
|
171
|
+
envIsEnabled(Bun.env.ZELLIJ) ||
|
|
172
|
+
envIsEnabled(Bun.env[SKC_TMUX_LAUNCHED_ENV]) ||
|
|
173
|
+
termLooksMultiplexed(Bun.env.TERM),
|
|
174
|
+
);
|
|
145
175
|
}
|
|
146
176
|
|
|
147
177
|
function useLegacyMultiplexerFullRender(): boolean {
|
|
148
178
|
return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
|
|
149
179
|
}
|
|
150
180
|
|
|
181
|
+
function useViewportRepaintPath(): boolean {
|
|
182
|
+
return isViewportRepaintSession() && !(isMultiplexerSession() && useLegacyMultiplexerFullRender());
|
|
183
|
+
}
|
|
184
|
+
|
|
151
185
|
/**
|
|
152
186
|
* Options for overlay positioning and sizing.
|
|
153
187
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
@@ -303,6 +337,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
|
|
|
303
337
|
type LineNormalizationCacheEntry = {
|
|
304
338
|
normalized: string;
|
|
305
339
|
terminated: string;
|
|
340
|
+
width: number | undefined;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
type TuiRenderCounterSnapshot = {
|
|
344
|
+
debugRedrawEnvReads: number;
|
|
345
|
+
debugRedrawAppendWrites: number;
|
|
346
|
+
differentialGuardVisibleWidthCalls: number;
|
|
306
347
|
};
|
|
307
348
|
|
|
308
349
|
/**
|
|
@@ -319,6 +360,7 @@ export class TUI extends Container {
|
|
|
319
360
|
*/
|
|
320
361
|
#previousRaw: string[] = [];
|
|
321
362
|
#lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
|
|
363
|
+
#lineEmitWidthCache = new Map<string, number>();
|
|
322
364
|
#lineTruncationCache = new Map<string, string>();
|
|
323
365
|
#lineNormalizationCacheLimit = 0;
|
|
324
366
|
#lineTruncationCacheLimit = 0;
|
|
@@ -349,20 +391,58 @@ export class TUI extends Container {
|
|
|
349
391
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
350
392
|
#sixelProbeUnsubscribe?: () => void;
|
|
351
393
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
394
|
+
#debugRedraw = TUI.#readDebugRedrawFlag();
|
|
352
395
|
// macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
|
|
353
396
|
readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
|
|
354
397
|
// showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
|
|
355
398
|
#imeCursorActive = false;
|
|
356
399
|
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
357
|
-
//
|
|
358
|
-
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical
|
|
359
|
-
|
|
400
|
+
// Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
|
|
401
|
+
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
|
|
402
|
+
// set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
|
|
403
|
+
#virtualViewport = $flag("PI_TUI_VIRTUAL_VIEWPORT", true);
|
|
360
404
|
#maxLinesRendered = 0; // Line count from last render, used for viewport calculation
|
|
361
405
|
#fullRedrawCount = 0;
|
|
362
406
|
#stopped = false;
|
|
363
407
|
#terminalUnavailable = false;
|
|
364
408
|
#bottomPinnedComponent: Component | null = null;
|
|
365
409
|
|
|
410
|
+
#unsubscribeTabWidthChange?: () => void;
|
|
411
|
+
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
412
|
+
debugRedrawEnvReads: 0,
|
|
413
|
+
debugRedrawAppendWrites: 0,
|
|
414
|
+
differentialGuardVisibleWidthCalls: 0,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
static resetRenderCountersForTest(): void {
|
|
418
|
+
TUI.#renderCounters = {
|
|
419
|
+
debugRedrawEnvReads: 0,
|
|
420
|
+
debugRedrawAppendWrites: 0,
|
|
421
|
+
differentialGuardVisibleWidthCalls: 0,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
static getRenderCountersForTest(): TuiRenderCounterSnapshot {
|
|
426
|
+
return { ...TUI.#renderCounters };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
static #readDebugRedrawFlag(): boolean {
|
|
430
|
+
TUI.#renderCounters.debugRedrawEnvReads += 1;
|
|
431
|
+
return $flag("PI_DEBUG_REDRAW");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#appendDebugRedrawLog(message: string): void {
|
|
435
|
+
TUI.#renderCounters.debugRedrawAppendWrites += 1;
|
|
436
|
+
fs.appendFileSync(getDebugLogPath(), message);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#visibleWidthForDifferentialGuard(line: string): number {
|
|
440
|
+
const cached = this.#lineEmitWidthCache.get(line);
|
|
441
|
+
if (cached !== undefined) return cached;
|
|
442
|
+
TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
|
|
443
|
+
return visibleWidth(line);
|
|
444
|
+
}
|
|
445
|
+
|
|
366
446
|
// Overlay stack for modal components rendered on top of base content
|
|
367
447
|
overlayStack: {
|
|
368
448
|
component: Component;
|
|
@@ -378,6 +458,18 @@ export class TUI extends Container {
|
|
|
378
458
|
this.#showHardwareCursor = showHardwareCursor;
|
|
379
459
|
}
|
|
380
460
|
this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
|
|
461
|
+
this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
|
|
462
|
+
this.#lineTruncationCache.clear();
|
|
463
|
+
this.#lineNormalizationCache.clear();
|
|
464
|
+
this.#lineEmitWidthCache.clear();
|
|
465
|
+
this.requestRender(true, "tab-width-change");
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
override dispose(): void {
|
|
470
|
+
this.#unsubscribeTabWidthChange?.();
|
|
471
|
+
this.#unsubscribeTabWidthChange = undefined;
|
|
472
|
+
super.dispose();
|
|
381
473
|
}
|
|
382
474
|
|
|
383
475
|
get fullRedraws(): number {
|
|
@@ -809,24 +901,26 @@ export class TUI extends Container {
|
|
|
809
901
|
this.#previousRaw = [];
|
|
810
902
|
this.#lineNormalizationCache.clear();
|
|
811
903
|
this.#lineTruncationCache.clear();
|
|
904
|
+
this.#lineEmitWidthCache.clear();
|
|
812
905
|
this.#previousWidth = 0;
|
|
813
906
|
this.#previousHeight = 0;
|
|
814
907
|
}
|
|
815
908
|
|
|
816
909
|
/**
|
|
817
|
-
*
|
|
910
|
+
* Viewport-repaint-aware resize render request.
|
|
818
911
|
*
|
|
819
912
|
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
820
913
|
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
821
914
|
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
822
915
|
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
823
916
|
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
824
|
-
* high speed" resize storm.
|
|
825
|
-
*
|
|
826
|
-
*
|
|
917
|
+
* high speed" resize storm. Windows Terminal can also visibly jump to the
|
|
918
|
+
* transcript top during streaming redraws, so viewport-repaint sessions keep
|
|
919
|
+
* force off and let `#doRender` repaint only the live viewport. Set
|
|
920
|
+
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
827
921
|
*/
|
|
828
922
|
requestResizeRender(): void {
|
|
829
|
-
this.requestRender(!(
|
|
923
|
+
this.requestRender(!useViewportRepaintPath() && !isTermuxSession(), "resize");
|
|
830
924
|
}
|
|
831
925
|
|
|
832
926
|
requestRender(force = false, source = "unknown"): void {
|
|
@@ -836,20 +930,24 @@ export class TUI extends Container {
|
|
|
836
930
|
}
|
|
837
931
|
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
838
932
|
if (force) {
|
|
933
|
+
const preserveViewportCursor = useViewportRepaintPath();
|
|
839
934
|
// A forced full redraw supersedes any queued input-priority render.
|
|
840
935
|
this.#inputRenderPending = false;
|
|
841
936
|
this.#previousLines = [];
|
|
842
937
|
this.#previousRaw = [];
|
|
843
938
|
this.#lineNormalizationCache.clear();
|
|
844
939
|
this.#lineTruncationCache.clear();
|
|
940
|
+
this.#lineEmitWidthCache.clear();
|
|
845
941
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
846
942
|
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
847
943
|
this.#lineNormalizationCacheLimit = 0;
|
|
848
944
|
this.#lineTruncationCacheLimit = 0;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
945
|
+
if (!preserveViewportCursor) {
|
|
946
|
+
this.#cursorRow = 0;
|
|
947
|
+
this.#hardwareCursorRow = 0;
|
|
948
|
+
this.#viewportTopRow = 0;
|
|
949
|
+
this.#maxLinesRendered = 0;
|
|
950
|
+
}
|
|
853
951
|
if (this.#renderTimer) {
|
|
854
952
|
clearTimeout(this.#renderTimer);
|
|
855
953
|
this.#renderTimer = undefined;
|
|
@@ -1315,24 +1413,9 @@ export class TUI extends Container {
|
|
|
1315
1413
|
if (cached !== undefined) return cached;
|
|
1316
1414
|
const normalized = normalizeTerminalOutput(line);
|
|
1317
1415
|
const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
#lineFitsWidth(normalizedLine: string, width: number): boolean {
|
|
1323
|
-
return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
|
|
1324
|
-
? true
|
|
1325
|
-
: visibleWidth(normalizedLine) <= width;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
#truncateNormalizedLine(normalizedLine: string, width: number): string {
|
|
1329
|
-
const key = `${width}\0${normalizedLine}`;
|
|
1330
|
-
const cached = this.#lineTruncationCache.get(key);
|
|
1331
|
-
if (cached !== undefined) return cached;
|
|
1332
|
-
const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
|
|
1333
|
-
const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1334
|
-
this.#lineTruncationCache.set(key, terminated);
|
|
1335
|
-
return terminated;
|
|
1416
|
+
const entry = { normalized, terminated, width: undefined };
|
|
1417
|
+
this.#lineNormalizationCache.set(line, entry);
|
|
1418
|
+
return entry;
|
|
1336
1419
|
}
|
|
1337
1420
|
|
|
1338
1421
|
#trimLineCachesForRender(lineCount: number): void {
|
|
@@ -1342,6 +1425,8 @@ export class TUI extends Container {
|
|
|
1342
1425
|
while (this.#lineNormalizationCache.size > limit) {
|
|
1343
1426
|
const key = this.#lineNormalizationCache.keys().next().value;
|
|
1344
1427
|
if (key === undefined) break;
|
|
1428
|
+
const entry = this.#lineNormalizationCache.get(key);
|
|
1429
|
+
if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
|
|
1345
1430
|
this.#lineNormalizationCache.delete(key);
|
|
1346
1431
|
}
|
|
1347
1432
|
while (this.#lineTruncationCache.size > limit) {
|
|
@@ -1349,6 +1434,11 @@ export class TUI extends Container {
|
|
|
1349
1434
|
if (key === undefined) break;
|
|
1350
1435
|
this.#lineTruncationCache.delete(key);
|
|
1351
1436
|
}
|
|
1437
|
+
while (this.#lineEmitWidthCache.size > limit * 2) {
|
|
1438
|
+
const key = this.#lineEmitWidthCache.keys().next().value;
|
|
1439
|
+
if (key === undefined) break;
|
|
1440
|
+
this.#lineEmitWidthCache.delete(key);
|
|
1441
|
+
}
|
|
1352
1442
|
}
|
|
1353
1443
|
|
|
1354
1444
|
getLineRenderCacheStats(): {
|
|
@@ -1365,17 +1455,66 @@ export class TUI extends Container {
|
|
|
1365
1455
|
};
|
|
1366
1456
|
}
|
|
1367
1457
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1458
|
+
#normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
|
|
1459
|
+
const widthCheckIndexes: number[] = [];
|
|
1460
|
+
const widthCheckLines: string[] = [];
|
|
1461
|
+
for (let i = start; i < lines.length; i++) {
|
|
1462
|
+
const line = lines[i];
|
|
1463
|
+
if (TERMINAL.isImageLine(line)) continue;
|
|
1464
|
+
const entry = this.#normalizeLineForRender(line);
|
|
1465
|
+
const { normalized, terminated } = entry;
|
|
1466
|
+
if (isPrintableAscii(normalized) && normalized.length <= width) {
|
|
1467
|
+
entry.width = normalized.length;
|
|
1468
|
+
this.#lineEmitWidthCache.set(terminated, normalized.length);
|
|
1469
|
+
lines[i] = terminated;
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
widthCheckIndexes.push(i);
|
|
1473
|
+
widthCheckLines.push(normalized);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
|
|
1477
|
+
const truncateIndexes: number[] = [];
|
|
1478
|
+
const truncateLines: string[] = [];
|
|
1479
|
+
for (let i = 0; i < widthCheckIndexes.length; i++) {
|
|
1480
|
+
const lineIndex = widthCheckIndexes[i];
|
|
1481
|
+
const normalized = widthCheckLines[i];
|
|
1482
|
+
const measuredWidth = widths[i] ?? 0;
|
|
1483
|
+
if (measuredWidth <= width) {
|
|
1484
|
+
const entry = this.#normalizeLineForRender(lines[lineIndex]);
|
|
1485
|
+
entry.width = measuredWidth;
|
|
1486
|
+
this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
|
|
1487
|
+
lines[lineIndex] = entry.terminated;
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const key = `${width}\0${normalized}`;
|
|
1492
|
+
const cached = this.#lineTruncationCache.get(key);
|
|
1493
|
+
if (cached !== undefined) {
|
|
1494
|
+
this.#lineEmitWidthCache.set(cached, width);
|
|
1495
|
+
lines[lineIndex] = cached;
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
truncateIndexes.push(lineIndex);
|
|
1499
|
+
truncateLines.push(normalized);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const truncated = truncateLines.length === 0 ? [] : truncateLinesToWidth(truncateLines, width, Ellipsis.Omit);
|
|
1503
|
+
for (let i = 0; i < truncateIndexes.length; i++) {
|
|
1504
|
+
const lineIndex = truncateIndexes[i];
|
|
1505
|
+
const normalized = truncateLines[i];
|
|
1506
|
+
const truncatedLine = truncated[i] ?? "";
|
|
1507
|
+
const terminated = truncatedLine + (truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1508
|
+
this.#lineTruncationCache.set(`${width}\0${normalized}`, terminated);
|
|
1509
|
+
this.#lineEmitWidthCache.set(terminated, width);
|
|
1510
|
+
lines[lineIndex] = terminated;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return lines;
|
|
1373
1514
|
}
|
|
1374
1515
|
|
|
1375
1516
|
#applyLineResetsAndTruncate(lines: string[], width: number): string[] {
|
|
1376
|
-
|
|
1377
|
-
lines[i] = this.#normalizeLineForEmit(lines[i], width);
|
|
1378
|
-
}
|
|
1517
|
+
this.#normalizeLinesForEmit(lines, width);
|
|
1379
1518
|
this.#trimLineCachesForRender(lines.length);
|
|
1380
1519
|
return lines;
|
|
1381
1520
|
}
|
|
@@ -1429,7 +1568,7 @@ export class TUI extends Container {
|
|
|
1429
1568
|
if (lineIndex >= lines.length) continue;
|
|
1430
1569
|
const line = lines[lineIndex];
|
|
1431
1570
|
const isImage = TERMINAL.isImageLine(line);
|
|
1432
|
-
if (!isImage &&
|
|
1571
|
+
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
1433
1572
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1434
1573
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1435
1574
|
buffer += truncatedLine;
|
|
@@ -1451,10 +1590,9 @@ export class TUI extends Container {
|
|
|
1451
1590
|
buffer += "\x1b[?2026l";
|
|
1452
1591
|
if (!this.#writeTerminal(buffer)) return false;
|
|
1453
1592
|
|
|
1454
|
-
if (
|
|
1455
|
-
const logPath = getDebugLogPath();
|
|
1593
|
+
if (this.#debugRedraw) {
|
|
1456
1594
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
1457
|
-
|
|
1595
|
+
this.#appendDebugRedrawLog(msg);
|
|
1458
1596
|
}
|
|
1459
1597
|
|
|
1460
1598
|
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
@@ -1531,8 +1669,9 @@ export class TUI extends Container {
|
|
|
1531
1669
|
if (stable) {
|
|
1532
1670
|
const windowed = this.#previousLines.slice(0, winTop);
|
|
1533
1671
|
for (let i = winTop; i < total; i++) {
|
|
1534
|
-
windowed.push(
|
|
1672
|
+
windowed.push(rawLines[i]);
|
|
1535
1673
|
}
|
|
1674
|
+
this.#normalizeLinesForEmit(windowed, width, winTop);
|
|
1536
1675
|
this.#trimLineCachesForRender(total);
|
|
1537
1676
|
newLines = windowed;
|
|
1538
1677
|
diffStart = winTop;
|
|
@@ -1606,7 +1745,7 @@ export class TUI extends Container {
|
|
|
1606
1745
|
this.#previousHeight = height;
|
|
1607
1746
|
};
|
|
1608
1747
|
|
|
1609
|
-
const
|
|
1748
|
+
const viewportRepaint = (reason: string): void => {
|
|
1610
1749
|
this.#fullRedrawCount += 1;
|
|
1611
1750
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
1612
1751
|
const nextViewportTop = Math.max(0, newLines.length - height);
|
|
@@ -1623,7 +1762,7 @@ export class TUI extends Container {
|
|
|
1623
1762
|
if (lineIndex >= newLines.length) continue;
|
|
1624
1763
|
const line = newLines[lineIndex];
|
|
1625
1764
|
const isImage = TERMINAL.isImageLine(line);
|
|
1626
|
-
if (!isImage &&
|
|
1765
|
+
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
1627
1766
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1628
1767
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1629
1768
|
buffer += truncatedLine;
|
|
@@ -1645,15 +1784,14 @@ export class TUI extends Container {
|
|
|
1645
1784
|
buffer += "\x1b[?2026l";
|
|
1646
1785
|
if (!this.#writeTerminal(buffer)) return;
|
|
1647
1786
|
|
|
1648
|
-
if (
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
fs.appendFileSync(logPath, msg);
|
|
1787
|
+
if (this.#debugRedraw) {
|
|
1788
|
+
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
1789
|
+
this.#appendDebugRedrawLog(msg);
|
|
1652
1790
|
}
|
|
1653
|
-
//
|
|
1791
|
+
// Viewport repaint deliberately prioritizes the live viewport over
|
|
1654
1792
|
// historical scrollback repair. After offscreen changes, #previousLines
|
|
1655
1793
|
// tracks the desired logical transcript, not every byte emitted into the
|
|
1656
|
-
//
|
|
1794
|
+
// terminal scrollback.
|
|
1657
1795
|
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1658
1796
|
this.#maxLinesRendered = newLines.length;
|
|
1659
1797
|
this.#viewportTopRow = nextViewportTop;
|
|
@@ -1662,12 +1800,11 @@ export class TUI extends Container {
|
|
|
1662
1800
|
this.#previousHeight = height;
|
|
1663
1801
|
};
|
|
1664
1802
|
|
|
1665
|
-
const debugRedraw =
|
|
1803
|
+
const debugRedraw = this.#debugRedraw;
|
|
1666
1804
|
const logRedraw = (reason: string): void => {
|
|
1667
1805
|
if (!debugRedraw) return;
|
|
1668
|
-
const logPath = getDebugLogPath();
|
|
1669
1806
|
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
|
1670
|
-
|
|
1807
|
+
this.#appendDebugRedrawLog(msg);
|
|
1671
1808
|
};
|
|
1672
1809
|
|
|
1673
1810
|
// First render - just output everything without clearing (assumes clean screen)
|
|
@@ -1680,13 +1817,12 @@ export class TUI extends Container {
|
|
|
1680
1817
|
// Width changes always need a full re-render because wrapping changes.
|
|
1681
1818
|
if (widthChanged) {
|
|
1682
1819
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1683
|
-
if (
|
|
1684
|
-
// In
|
|
1685
|
-
// scrollback (
|
|
1686
|
-
//
|
|
1687
|
-
// width
|
|
1688
|
-
|
|
1689
|
-
multiplexerViewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1820
|
+
if (useViewportRepaintPath()) {
|
|
1821
|
+
// In viewport-repaint sessions a full replay can either pile the transcript
|
|
1822
|
+
// back onto scrollback (tmux/screen) or visibly jump to the transcript top
|
|
1823
|
+
// (Windows Terminal). Repaint the viewport only, mirroring the height-change
|
|
1824
|
+
// branch and neutralizing fake width changes from requestRender(true).
|
|
1825
|
+
viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1690
1826
|
} else {
|
|
1691
1827
|
fullRender(true, "terminal width changed");
|
|
1692
1828
|
}
|
|
@@ -1697,8 +1833,8 @@ export class TUI extends Container {
|
|
|
1697
1833
|
// but Termux changes height when the software keyboard shows or hides.
|
|
1698
1834
|
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
1699
1835
|
if (heightChanged) {
|
|
1700
|
-
if (
|
|
1701
|
-
|
|
1836
|
+
if (useViewportRepaintPath()) {
|
|
1837
|
+
viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
1702
1838
|
return;
|
|
1703
1839
|
}
|
|
1704
1840
|
if (!isTermuxSession() && !isMultiplexerSession()) {
|
|
@@ -1713,7 +1849,11 @@ export class TUI extends Container {
|
|
|
1713
1849
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
1714
1850
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
1715
1851
|
logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1716
|
-
|
|
1852
|
+
if (useViewportRepaintPath()) {
|
|
1853
|
+
viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1854
|
+
} else {
|
|
1855
|
+
fullRender(true, "clearOnShrink");
|
|
1856
|
+
}
|
|
1717
1857
|
return;
|
|
1718
1858
|
}
|
|
1719
1859
|
|
|
@@ -1751,6 +1891,11 @@ export class TUI extends Container {
|
|
|
1751
1891
|
return;
|
|
1752
1892
|
}
|
|
1753
1893
|
|
|
1894
|
+
const nextLiveViewportTop = Math.max(0, newLines.length - height);
|
|
1895
|
+
if (firstChanged >= newLines.length && nextLiveViewportTop !== prevViewportTop) {
|
|
1896
|
+
viewportRepaint(`tail shrink changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1754
1899
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
1755
1900
|
if (firstChanged >= newLines.length) {
|
|
1756
1901
|
if (this.#previousLines.length > newLines.length) {
|
|
@@ -1765,8 +1910,8 @@ export class TUI extends Container {
|
|
|
1765
1910
|
const extraLines = this.#previousLines.length - newLines.length;
|
|
1766
1911
|
if (extraLines > height) {
|
|
1767
1912
|
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
1768
|
-
if (
|
|
1769
|
-
|
|
1913
|
+
if (useViewportRepaintPath()) {
|
|
1914
|
+
viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
|
|
1770
1915
|
} else {
|
|
1771
1916
|
fullRender(true, "extraLines > height");
|
|
1772
1917
|
}
|
|
@@ -1799,16 +1944,19 @@ export class TUI extends Container {
|
|
|
1799
1944
|
return;
|
|
1800
1945
|
}
|
|
1801
1946
|
|
|
1802
|
-
// Differential rendering can only touch what was actually visible.
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1947
|
+
// Differential rendering can only touch what was actually visible. If a
|
|
1948
|
+
// streaming status/header line changes above a live-following viewport, keep
|
|
1949
|
+
// the terminal pinned by diffing from the visible top instead of clearing and
|
|
1950
|
+
// replaying the transcript. If the user paged away, keep the historical
|
|
1951
|
+
// full-redraw behavior so scrollback is repaired rather than snapping them
|
|
1952
|
+
// back to live.
|
|
1805
1953
|
if (firstChanged < prevViewportTop) {
|
|
1806
1954
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1807
|
-
if (
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
fullRender(true, "firstChanged < viewportTop");
|
|
1955
|
+
if (useViewportRepaintPath()) {
|
|
1956
|
+
viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1957
|
+
return;
|
|
1811
1958
|
}
|
|
1959
|
+
fullRender(true, "firstChanged < viewportTop");
|
|
1812
1960
|
return;
|
|
1813
1961
|
}
|
|
1814
1962
|
|
|
@@ -1849,16 +1997,17 @@ export class TUI extends Container {
|
|
|
1849
1997
|
const line = newLines[i];
|
|
1850
1998
|
let truncatedLine = line;
|
|
1851
1999
|
const isImage = TERMINAL.isImageLine(line);
|
|
1852
|
-
|
|
2000
|
+
const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
|
|
2001
|
+
if (!isImage && lineWidth > width) {
|
|
1853
2002
|
if (debugRedraw) {
|
|
1854
2003
|
const debugData = [
|
|
1855
2004
|
`[TUI Truncate] ${new Date().toISOString()}`,
|
|
1856
|
-
`Line ${i} truncated: ${
|
|
2005
|
+
`Line ${i} truncated: ${lineWidth} > ${width}`,
|
|
1857
2006
|
`Content preview: ${line.slice(0, 100)}...`,
|
|
1858
2007
|
"",
|
|
1859
2008
|
].join("\n");
|
|
1860
2009
|
try {
|
|
1861
|
-
|
|
2010
|
+
this.#appendDebugRedrawLog(debugData);
|
|
1862
2011
|
} catch {
|
|
1863
2012
|
// Ignore write errors - truncation should still work
|
|
1864
2013
|
}
|