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