@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/CHANGELOG.md +11 -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/terminal-capabilities.d.ts +70 -2
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +22 -4
- package/dist/types/utils.d.ts +12 -0
- package/package.json +4 -7
- package/src/animation-scheduler.ts +99 -0
- package/src/components/editor.ts +296 -167
- package/src/components/image.ts +43 -9
- 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/terminal-capabilities.ts +149 -7
- package/src/terminal.ts +7 -0
- package/src/tui.ts +259 -101
- 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";
|
|
@@ -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(
|
|
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(
|
|
159
|
-
envIsEnabled(
|
|
160
|
-
envIsEnabled(
|
|
161
|
-
envIsEnabled(
|
|
162
|
-
envIsEnabled(
|
|
163
|
-
termLooksMultiplexed(
|
|
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
|
|
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
|
-
//
|
|
378
|
-
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
845
|
-
*
|
|
846
|
-
*
|
|
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(!(
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1580
|
-
|
|
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
|
|
1604
|
-
|
|
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
|
|
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 &&
|
|
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 (
|
|
1669
|
-
const
|
|
1670
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
1704
|
-
// In
|
|
1705
|
-
// scrollback (
|
|
1706
|
-
//
|
|
1707
|
-
// width
|
|
1708
|
-
|
|
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 (
|
|
1721
|
-
|
|
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
|
-
|
|
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 (
|
|
1789
|
-
|
|
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
|
-
//
|
|
1824
|
-
//
|
|
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 (
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
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: ${
|
|
2034
|
+
`Line ${i} truncated: ${lineWidth} > ${width}`,
|
|
1877
2035
|
`Content preview: ${line.slice(0, 100)}...`,
|
|
1878
2036
|
"",
|
|
1879
2037
|
].join("\n");
|
|
1880
2038
|
try {
|
|
1881
|
-
|
|
2039
|
+
this.#appendDebugRedrawLog(debugData);
|
|
1882
2040
|
} catch {
|
|
1883
2041
|
// Ignore write errors - truncation should still work
|
|
1884
2042
|
}
|