@sayknow-cli/tui 0.3.7 → 0.3.9
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/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 -4
- package/src/animation-scheduler.ts +99 -0
- package/src/autocomplete.ts +119 -96
- package/src/components/editor.ts +315 -175
- package/src/components/image.ts +43 -9
- package/src/components/loader.ts +36 -37
- package/src/components/markdown.ts +79 -2
- package/src/components/settings-list.ts +4 -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 +274 -96
- 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";
|
|
@@ -135,17 +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);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
|
|
145
|
+
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
146
|
+
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
|
|
147
|
+
|
|
148
|
+
function envIsEnabled(value: string | undefined): boolean {
|
|
149
|
+
const normalized = value?.trim().toLowerCase();
|
|
150
|
+
return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
|
|
151
|
+
}
|
|
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
|
+
|
|
158
|
+
function termLooksMultiplexed(value: string | undefined): boolean {
|
|
159
|
+
const term = value?.trim().toLowerCase() ?? "";
|
|
160
|
+
return term.startsWith("tmux") || term.startsWith("screen");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
164
|
+
return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
143
|
-
function isMultiplexerSession(): boolean {
|
|
144
|
-
return Boolean(
|
|
168
|
+
function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
169
|
+
return Boolean(
|
|
170
|
+
envIsEnabled(env.TMUX) ||
|
|
171
|
+
envIsEnabled(env.TMUX_PANE) ||
|
|
172
|
+
envIsEnabled(env.STY) ||
|
|
173
|
+
envIsEnabled(env.ZELLIJ) ||
|
|
174
|
+
envIsEnabled(env[SKC_TMUX_LAUNCHED_ENV]) ||
|
|
175
|
+
termLooksMultiplexed(env.TERM),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
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
|
+
);
|
|
145
207
|
}
|
|
146
208
|
|
|
147
|
-
function
|
|
148
|
-
return
|
|
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);
|
|
149
217
|
}
|
|
150
218
|
|
|
151
219
|
/**
|
|
@@ -303,6 +371,13 @@ function safeRenderComponent(component: Component, width: number, where: string)
|
|
|
303
371
|
type LineNormalizationCacheEntry = {
|
|
304
372
|
normalized: string;
|
|
305
373
|
terminated: string;
|
|
374
|
+
width: number | undefined;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
type TuiRenderCounterSnapshot = {
|
|
378
|
+
debugRedrawEnvReads: number;
|
|
379
|
+
debugRedrawAppendWrites: number;
|
|
380
|
+
differentialGuardVisibleWidthCalls: number;
|
|
306
381
|
};
|
|
307
382
|
|
|
308
383
|
/**
|
|
@@ -319,6 +394,7 @@ export class TUI extends Container {
|
|
|
319
394
|
*/
|
|
320
395
|
#previousRaw: string[] = [];
|
|
321
396
|
#lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
|
|
397
|
+
#lineEmitWidthCache = new Map<string, number>();
|
|
322
398
|
#lineTruncationCache = new Map<string, string>();
|
|
323
399
|
#lineNormalizationCacheLimit = 0;
|
|
324
400
|
#lineTruncationCacheLimit = 0;
|
|
@@ -349,20 +425,58 @@ export class TUI extends Container {
|
|
|
349
425
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
350
426
|
#sixelProbeUnsubscribe?: () => void;
|
|
351
427
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
428
|
+
#debugRedraw = TUI.#readDebugRedrawFlag();
|
|
352
429
|
// macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
|
|
353
430
|
readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
|
|
354
431
|
// showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
|
|
355
432
|
#imeCursorActive = false;
|
|
356
433
|
#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
|
-
|
|
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);
|
|
360
438
|
#maxLinesRendered = 0; // Line count from last render, used for viewport calculation
|
|
361
439
|
#fullRedrawCount = 0;
|
|
362
440
|
#stopped = false;
|
|
363
441
|
#terminalUnavailable = false;
|
|
364
442
|
#bottomPinnedComponent: Component | null = null;
|
|
365
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
|
+
|
|
366
480
|
// Overlay stack for modal components rendered on top of base content
|
|
367
481
|
overlayStack: {
|
|
368
482
|
component: Component;
|
|
@@ -378,6 +492,18 @@ export class TUI extends Container {
|
|
|
378
492
|
this.#showHardwareCursor = showHardwareCursor;
|
|
379
493
|
}
|
|
380
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();
|
|
381
507
|
}
|
|
382
508
|
|
|
383
509
|
get fullRedraws(): number {
|
|
@@ -438,19 +564,13 @@ export class TUI extends Container {
|
|
|
438
564
|
const pageStep = Math.max(1, height - 1);
|
|
439
565
|
const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
|
|
440
566
|
|
|
441
|
-
|
|
442
|
-
this.#manualViewportTop = undefined;
|
|
443
|
-
} else {
|
|
444
|
-
this.#manualViewportTop = targetViewportTop;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
|
|
567
|
+
this.#manualViewportTop = targetViewportTop;
|
|
448
568
|
return this.#repaintViewportFromLines(
|
|
449
569
|
this.#previousLines,
|
|
450
570
|
width,
|
|
451
571
|
height,
|
|
452
572
|
targetViewportTop,
|
|
453
|
-
|
|
573
|
+
null,
|
|
454
574
|
"manual viewport scroll",
|
|
455
575
|
);
|
|
456
576
|
}
|
|
@@ -809,24 +929,26 @@ export class TUI extends Container {
|
|
|
809
929
|
this.#previousRaw = [];
|
|
810
930
|
this.#lineNormalizationCache.clear();
|
|
811
931
|
this.#lineTruncationCache.clear();
|
|
932
|
+
this.#lineEmitWidthCache.clear();
|
|
812
933
|
this.#previousWidth = 0;
|
|
813
934
|
this.#previousHeight = 0;
|
|
814
935
|
}
|
|
815
936
|
|
|
816
937
|
/**
|
|
817
|
-
*
|
|
938
|
+
* Viewport-repaint-aware resize render request.
|
|
818
939
|
*
|
|
819
940
|
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
820
941
|
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
821
942
|
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
822
943
|
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
823
944
|
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
824
|
-
* high speed" resize storm.
|
|
825
|
-
*
|
|
826
|
-
*
|
|
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.
|
|
827
949
|
*/
|
|
828
950
|
requestResizeRender(): void {
|
|
829
|
-
this.requestRender(!(
|
|
951
|
+
this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
|
|
830
952
|
}
|
|
831
953
|
|
|
832
954
|
requestRender(force = false, source = "unknown"): void {
|
|
@@ -836,20 +958,24 @@ export class TUI extends Container {
|
|
|
836
958
|
}
|
|
837
959
|
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
838
960
|
if (force) {
|
|
961
|
+
const preserveViewportCursor = useViewportRepaintPath(this.terminal);
|
|
839
962
|
// A forced full redraw supersedes any queued input-priority render.
|
|
840
963
|
this.#inputRenderPending = false;
|
|
841
964
|
this.#previousLines = [];
|
|
842
965
|
this.#previousRaw = [];
|
|
843
966
|
this.#lineNormalizationCache.clear();
|
|
844
967
|
this.#lineTruncationCache.clear();
|
|
968
|
+
this.#lineEmitWidthCache.clear();
|
|
845
969
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
846
970
|
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
847
971
|
this.#lineNormalizationCacheLimit = 0;
|
|
848
972
|
this.#lineTruncationCacheLimit = 0;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
973
|
+
if (!preserveViewportCursor) {
|
|
974
|
+
this.#cursorRow = 0;
|
|
975
|
+
this.#hardwareCursorRow = 0;
|
|
976
|
+
this.#viewportTopRow = 0;
|
|
977
|
+
this.#maxLinesRendered = 0;
|
|
978
|
+
}
|
|
853
979
|
if (this.#renderTimer) {
|
|
854
980
|
clearTimeout(this.#renderTimer);
|
|
855
981
|
this.#renderTimer = undefined;
|
|
@@ -1315,24 +1441,9 @@ export class TUI extends Container {
|
|
|
1315
1441
|
if (cached !== undefined) return cached;
|
|
1316
1442
|
const normalized = normalizeTerminalOutput(line);
|
|
1317
1443
|
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;
|
|
1444
|
+
const entry = { normalized, terminated, width: undefined };
|
|
1445
|
+
this.#lineNormalizationCache.set(line, entry);
|
|
1446
|
+
return entry;
|
|
1336
1447
|
}
|
|
1337
1448
|
|
|
1338
1449
|
#trimLineCachesForRender(lineCount: number): void {
|
|
@@ -1342,6 +1453,8 @@ export class TUI extends Container {
|
|
|
1342
1453
|
while (this.#lineNormalizationCache.size > limit) {
|
|
1343
1454
|
const key = this.#lineNormalizationCache.keys().next().value;
|
|
1344
1455
|
if (key === undefined) break;
|
|
1456
|
+
const entry = this.#lineNormalizationCache.get(key);
|
|
1457
|
+
if (entry !== undefined) this.#lineEmitWidthCache.delete(entry.terminated);
|
|
1345
1458
|
this.#lineNormalizationCache.delete(key);
|
|
1346
1459
|
}
|
|
1347
1460
|
while (this.#lineTruncationCache.size > limit) {
|
|
@@ -1349,6 +1462,11 @@ export class TUI extends Container {
|
|
|
1349
1462
|
if (key === undefined) break;
|
|
1350
1463
|
this.#lineTruncationCache.delete(key);
|
|
1351
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
|
+
}
|
|
1352
1470
|
}
|
|
1353
1471
|
|
|
1354
1472
|
getLineRenderCacheStats(): {
|
|
@@ -1365,17 +1483,66 @@ export class TUI extends Container {
|
|
|
1365
1483
|
};
|
|
1366
1484
|
}
|
|
1367
1485
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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;
|
|
1373
1542
|
}
|
|
1374
1543
|
|
|
1375
1544
|
#applyLineResetsAndTruncate(lines: string[], width: number): string[] {
|
|
1376
|
-
|
|
1377
|
-
lines[i] = this.#normalizeLineForEmit(lines[i], width);
|
|
1378
|
-
}
|
|
1545
|
+
this.#normalizeLinesForEmit(lines, width);
|
|
1379
1546
|
this.#trimLineCachesForRender(lines.length);
|
|
1380
1547
|
return lines;
|
|
1381
1548
|
}
|
|
@@ -1429,7 +1596,7 @@ export class TUI extends Container {
|
|
|
1429
1596
|
if (lineIndex >= lines.length) continue;
|
|
1430
1597
|
const line = lines[lineIndex];
|
|
1431
1598
|
const isImage = TERMINAL.isImageLine(line);
|
|
1432
|
-
if (!isImage &&
|
|
1599
|
+
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
1433
1600
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1434
1601
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1435
1602
|
buffer += truncatedLine;
|
|
@@ -1451,10 +1618,9 @@ export class TUI extends Container {
|
|
|
1451
1618
|
buffer += "\x1b[?2026l";
|
|
1452
1619
|
if (!this.#writeTerminal(buffer)) return false;
|
|
1453
1620
|
|
|
1454
|
-
if (
|
|
1455
|
-
const logPath = getDebugLogPath();
|
|
1621
|
+
if (this.#debugRedraw) {
|
|
1456
1622
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
1457
|
-
|
|
1623
|
+
this.#appendDebugRedrawLog(msg);
|
|
1458
1624
|
}
|
|
1459
1625
|
|
|
1460
1626
|
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
@@ -1531,8 +1697,9 @@ export class TUI extends Container {
|
|
|
1531
1697
|
if (stable) {
|
|
1532
1698
|
const windowed = this.#previousLines.slice(0, winTop);
|
|
1533
1699
|
for (let i = winTop; i < total; i++) {
|
|
1534
|
-
windowed.push(
|
|
1700
|
+
windowed.push(rawLines[i]);
|
|
1535
1701
|
}
|
|
1702
|
+
this.#normalizeLinesForEmit(windowed, width, winTop);
|
|
1536
1703
|
this.#trimLineCachesForRender(total);
|
|
1537
1704
|
newLines = windowed;
|
|
1538
1705
|
diffStart = winTop;
|
|
@@ -1556,9 +1723,8 @@ export class TUI extends Container {
|
|
|
1556
1723
|
if (this.#manualViewportTop !== undefined) {
|
|
1557
1724
|
const maxViewportTop = Math.max(0, newLines.length - height);
|
|
1558
1725
|
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
const repaintCursorPos = followingLive ? cursorPos : null;
|
|
1726
|
+
this.#manualViewportTop = nextViewportTop;
|
|
1727
|
+
const repaintCursorPos = null;
|
|
1562
1728
|
if (
|
|
1563
1729
|
this.#repaintViewportFromLines(
|
|
1564
1730
|
newLines,
|
|
@@ -1580,8 +1746,10 @@ export class TUI extends Container {
|
|
|
1580
1746
|
this.#fullRedrawCount += 1;
|
|
1581
1747
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
1582
1748
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1583
|
-
// Skip clearing scrollback (3J) in
|
|
1584
|
-
|
|
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";
|
|
1585
1753
|
for (let i = 0; i < newLines.length; i++) {
|
|
1586
1754
|
if (i > 0) buffer += "\r\n";
|
|
1587
1755
|
// Lines were pre-terminated/normalized by #applyLineResets; image
|
|
@@ -1606,7 +1774,7 @@ export class TUI extends Container {
|
|
|
1606
1774
|
this.#previousHeight = height;
|
|
1607
1775
|
};
|
|
1608
1776
|
|
|
1609
|
-
const
|
|
1777
|
+
const viewportRepaint = (reason: string): void => {
|
|
1610
1778
|
this.#fullRedrawCount += 1;
|
|
1611
1779
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
1612
1780
|
const nextViewportTop = Math.max(0, newLines.length - height);
|
|
@@ -1623,7 +1791,7 @@ export class TUI extends Container {
|
|
|
1623
1791
|
if (lineIndex >= newLines.length) continue;
|
|
1624
1792
|
const line = newLines[lineIndex];
|
|
1625
1793
|
const isImage = TERMINAL.isImageLine(line);
|
|
1626
|
-
if (!isImage &&
|
|
1794
|
+
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
1627
1795
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1628
1796
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1629
1797
|
buffer += truncatedLine;
|
|
@@ -1645,15 +1813,14 @@ export class TUI extends Container {
|
|
|
1645
1813
|
buffer += "\x1b[?2026l";
|
|
1646
1814
|
if (!this.#writeTerminal(buffer)) return;
|
|
1647
1815
|
|
|
1648
|
-
if (
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
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);
|
|
1652
1819
|
}
|
|
1653
|
-
//
|
|
1820
|
+
// Viewport repaint deliberately prioritizes the live viewport over
|
|
1654
1821
|
// historical scrollback repair. After offscreen changes, #previousLines
|
|
1655
1822
|
// tracks the desired logical transcript, not every byte emitted into the
|
|
1656
|
-
//
|
|
1823
|
+
// terminal scrollback.
|
|
1657
1824
|
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1658
1825
|
this.#maxLinesRendered = newLines.length;
|
|
1659
1826
|
this.#viewportTopRow = nextViewportTop;
|
|
@@ -1662,12 +1829,11 @@ export class TUI extends Container {
|
|
|
1662
1829
|
this.#previousHeight = height;
|
|
1663
1830
|
};
|
|
1664
1831
|
|
|
1665
|
-
const debugRedraw =
|
|
1832
|
+
const debugRedraw = this.#debugRedraw;
|
|
1666
1833
|
const logRedraw = (reason: string): void => {
|
|
1667
1834
|
if (!debugRedraw) return;
|
|
1668
|
-
const logPath = getDebugLogPath();
|
|
1669
1835
|
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
|
1670
|
-
|
|
1836
|
+
this.#appendDebugRedrawLog(msg);
|
|
1671
1837
|
};
|
|
1672
1838
|
|
|
1673
1839
|
// First render - just output everything without clearing (assumes clean screen)
|
|
@@ -1680,13 +1846,12 @@ export class TUI extends Container {
|
|
|
1680
1846
|
// Width changes always need a full re-render because wrapping changes.
|
|
1681
1847
|
if (widthChanged) {
|
|
1682
1848
|
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})`);
|
|
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})`);
|
|
1690
1855
|
} else {
|
|
1691
1856
|
fullRender(true, "terminal width changed");
|
|
1692
1857
|
}
|
|
@@ -1697,8 +1862,8 @@ export class TUI extends Container {
|
|
|
1697
1862
|
// but Termux changes height when the software keyboard shows or hides.
|
|
1698
1863
|
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
1699
1864
|
if (heightChanged) {
|
|
1700
|
-
if (
|
|
1701
|
-
|
|
1865
|
+
if (useViewportRepaintPath(this.terminal)) {
|
|
1866
|
+
viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
1702
1867
|
return;
|
|
1703
1868
|
}
|
|
1704
1869
|
if (!isTermuxSession() && !isMultiplexerSession()) {
|
|
@@ -1713,7 +1878,11 @@ export class TUI extends Container {
|
|
|
1713
1878
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
1714
1879
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
1715
1880
|
logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1716
|
-
|
|
1881
|
+
if (useViewportRepaintPath(this.terminal)) {
|
|
1882
|
+
viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1883
|
+
} else {
|
|
1884
|
+
fullRender(true, "clearOnShrink");
|
|
1885
|
+
}
|
|
1717
1886
|
return;
|
|
1718
1887
|
}
|
|
1719
1888
|
|
|
@@ -1751,6 +1920,11 @@ export class TUI extends Container {
|
|
|
1751
1920
|
return;
|
|
1752
1921
|
}
|
|
1753
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
|
+
}
|
|
1754
1928
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
1755
1929
|
if (firstChanged >= newLines.length) {
|
|
1756
1930
|
if (this.#previousLines.length > newLines.length) {
|
|
@@ -1765,8 +1939,8 @@ export class TUI extends Container {
|
|
|
1765
1939
|
const extraLines = this.#previousLines.length - newLines.length;
|
|
1766
1940
|
if (extraLines > height) {
|
|
1767
1941
|
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
1768
|
-
if (
|
|
1769
|
-
|
|
1942
|
+
if (useViewportRepaintPath(this.terminal)) {
|
|
1943
|
+
viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
|
|
1770
1944
|
} else {
|
|
1771
1945
|
fullRender(true, "extraLines > height");
|
|
1772
1946
|
}
|
|
@@ -1799,16 +1973,19 @@ export class TUI extends Container {
|
|
|
1799
1973
|
return;
|
|
1800
1974
|
}
|
|
1801
1975
|
|
|
1802
|
-
// Differential rendering can only touch what was actually visible.
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
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.
|
|
1805
1982
|
if (firstChanged < prevViewportTop) {
|
|
1806
1983
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1807
|
-
if (
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
fullRender(true, "firstChanged < viewportTop");
|
|
1984
|
+
if (useViewportRepaintPath(this.terminal)) {
|
|
1985
|
+
viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1986
|
+
return;
|
|
1811
1987
|
}
|
|
1988
|
+
fullRender(true, "firstChanged < viewportTop");
|
|
1812
1989
|
return;
|
|
1813
1990
|
}
|
|
1814
1991
|
|
|
@@ -1849,16 +2026,17 @@ export class TUI extends Container {
|
|
|
1849
2026
|
const line = newLines[i];
|
|
1850
2027
|
let truncatedLine = line;
|
|
1851
2028
|
const isImage = TERMINAL.isImageLine(line);
|
|
1852
|
-
|
|
2029
|
+
const lineWidth = isImage ? 0 : this.#visibleWidthForDifferentialGuard(line);
|
|
2030
|
+
if (!isImage && lineWidth > width) {
|
|
1853
2031
|
if (debugRedraw) {
|
|
1854
2032
|
const debugData = [
|
|
1855
2033
|
`[TUI Truncate] ${new Date().toISOString()}`,
|
|
1856
|
-
`Line ${i} truncated: ${
|
|
2034
|
+
`Line ${i} truncated: ${lineWidth} > ${width}`,
|
|
1857
2035
|
`Content preview: ${line.slice(0, 100)}...`,
|
|
1858
2036
|
"",
|
|
1859
2037
|
].join("\n");
|
|
1860
2038
|
try {
|
|
1861
|
-
|
|
2039
|
+
this.#appendDebugRedrawLog(debugData);
|
|
1862
2040
|
} catch {
|
|
1863
2041
|
// Ignore write errors - truncation should still work
|
|
1864
2042
|
}
|