@oh-my-pi/pi-tui 18.4.0 → 18.4.2
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 +42 -0
- package/dist/types/chat/image-loading.d.ts +24 -0
- package/dist/types/chat/transcript-entry.d.ts +9 -0
- package/dist/types/chrome/transcript-container.d.ts +24 -3
- package/dist/types/overlays/agents-hub.d.ts +7 -4
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +4 -3
- package/dist/types/overlays/usage-dashboard.d.ts +9 -2
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/composer-cache.d.ts +34 -16
- package/dist/types/prompt/composer.d.ts +15 -18
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/prompt/welcome.d.ts +5 -2
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/status-line/component.d.ts +13 -4
- package/dist/types/status-line/metrics.d.ts +0 -1
- package/dist/types/status-line/startup.d.ts +38 -0
- package/dist/types/status-line/types.d.ts +0 -2
- package/dist/types/terminal-capabilities.d.ts +10 -6
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tools/output-meta.d.ts +2 -1
- package/dist/types/tools/vibe.d.ts +4 -0
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +27 -21
- package/src/chat/image-loading.ts +81 -0
- package/src/chat/tool-execution.ts +53 -17
- package/src/chat/transcript-entry.ts +16 -0
- package/src/chrome/transcript-container.ts +282 -86
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +25 -9
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/agents-hub.ts +7 -18
- package/src/overlays/copy-selector.ts +8 -31
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-hub.ts +49 -16
- package/src/overlays/rewind-selector.ts +60 -13
- package/src/overlays/usage-dashboard.ts +47 -6
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer-cache.ts +217 -196
- package/src/prompt/composer.ts +45 -72
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/prompt/welcome.ts +31 -28
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +286 -182
- package/src/status-line/metrics.ts +3 -21
- package/src/status-line/segments.ts +68 -79
- package/src/status-line/startup.ts +181 -0
- package/src/status-line/types.ts +0 -2
- package/src/terminal-capabilities.ts +12 -6
- package/src/terminal.ts +75 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/tools/output-meta.ts +11 -3
- package/src/tools/vibe.ts +15 -2
- package/src/tui.ts +208 -154
- package/src/utils.ts +15 -0
package/src/tools/vibe.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface VibeToolDetails {
|
|
|
23
23
|
op: VibeOp;
|
|
24
24
|
/** Live TV-wall snapshot of the owner's worker sessions at (or during) the call. */
|
|
25
25
|
screens: VibeScreenSnapshot[];
|
|
26
|
+
/** Sessions the director killed, left off `screens`; shown as a count. */
|
|
27
|
+
hiddenKilled?: string[];
|
|
26
28
|
spawned?: { id: string; cli: VibeCli; jobId: string };
|
|
27
29
|
send?: VibeSendOutcome;
|
|
28
30
|
wait?: {
|
|
@@ -46,6 +48,8 @@ export interface VibeScreenSnapshot {
|
|
|
46
48
|
id: string;
|
|
47
49
|
cli: VibeCli;
|
|
48
50
|
state: VibeSessionState;
|
|
51
|
+
/** Terminated by the director (`vibe_kill`) or mode teardown; unset for workers that died on their own. */
|
|
52
|
+
killed?: boolean;
|
|
49
53
|
model?: string;
|
|
50
54
|
turns: number;
|
|
51
55
|
queued: number;
|
|
@@ -373,13 +377,21 @@ export function createVibeToolRenderer(op: VibeOp) {
|
|
|
373
377
|
return new Text(header, 0, 0);
|
|
374
378
|
}
|
|
375
379
|
|
|
376
|
-
// wait/list: the TV wall.
|
|
380
|
+
// wait/list: the TV wall. Director-killed sessions arrive pre-filtered
|
|
381
|
+
// (see `hiddenKilled`) so a long-running director's wall stays legible.
|
|
377
382
|
const screens = details.screens;
|
|
383
|
+
const hiddenKilled = details.hiddenKilled?.length ?? 0;
|
|
384
|
+
const killedMeta = hiddenKilled > 0 ? [uiTheme.fg("dim", `${hiddenKilled} killed hidden`)] : [];
|
|
378
385
|
if (screens.length === 0) {
|
|
379
386
|
const fallback = result.content.find(part => part.type === "text")?.text ?? "no sessions";
|
|
387
|
+
const gist = hiddenKilled > 0 ? "no live sessions" : fallback;
|
|
380
388
|
return new Text(
|
|
381
389
|
renderStatusLine(
|
|
382
|
-
{
|
|
390
|
+
{
|
|
391
|
+
icon: "warning",
|
|
392
|
+
title: `vibe ${op}`,
|
|
393
|
+
meta: [uiTheme.fg("dim", frameText(gist, 60)), ...killedMeta],
|
|
394
|
+
},
|
|
383
395
|
uiTheme,
|
|
384
396
|
),
|
|
385
397
|
0,
|
|
@@ -394,6 +406,7 @@ export function createVibeToolRenderer(op: VibeOp) {
|
|
|
394
406
|
if (running > 0) meta.push(uiTheme.fg("accent", `${running} on air`));
|
|
395
407
|
if (settledById.size > 0) meta.push(uiTheme.fg("success", `${settledById.size} settled`));
|
|
396
408
|
if (details.wait?.timedOut) meta.push(uiTheme.fg("warning", "timed out"));
|
|
409
|
+
meta.push(...killedMeta);
|
|
397
410
|
const title =
|
|
398
411
|
op === "wait"
|
|
399
412
|
? waiting
|
package/src/tui.ts
CHANGED
|
@@ -604,100 +604,95 @@ function isSgrParamByte(c: number): boolean {
|
|
|
604
604
|
// standalone fg-red), changing the rendered color. The self-delimiting colon
|
|
605
605
|
// form (`38:2::r:g:b`) is unambiguous — its tokens never equal a bare `38`, so
|
|
606
606
|
// the scan treats it as a complete unit and merging stays safe.
|
|
607
|
-
function endsWithIncompleteExtendedColor(
|
|
608
|
-
|
|
609
|
-
let
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
607
|
+
function endsWithIncompleteExtendedColor(line: string, start: number, end: number): boolean {
|
|
608
|
+
let tokenStart = start;
|
|
609
|
+
let needsMode = false;
|
|
610
|
+
let valuesRemaining = 0;
|
|
611
|
+
for (let i = start; i <= end; i++) {
|
|
612
|
+
if (i !== end && line.charCodeAt(i) !== CC_SEMI) continue;
|
|
613
|
+
const tokenLength = i - tokenStart;
|
|
614
|
+
const first = line.charCodeAt(tokenStart);
|
|
615
|
+
if (valuesRemaining > 0) {
|
|
616
|
+
valuesRemaining--;
|
|
617
|
+
} else if (needsMode && tokenLength === 1 && (first === 0x32 || first === 0x35)) {
|
|
618
|
+
valuesRemaining = first === 0x32 ? 3 : 1;
|
|
619
|
+
needsMode = false;
|
|
620
|
+
} else {
|
|
621
|
+
// An unrecognized mode is also a standalone token: `38;48;5`
|
|
622
|
+
// still ends in an incomplete background palette spec.
|
|
623
|
+
needsMode = tokenLength === 2 && first >= 0x33 && first <= 0x35 && line.charCodeAt(tokenStart + 1) === 0x38;
|
|
625
624
|
}
|
|
626
|
-
i
|
|
625
|
+
tokenStart = i + 1;
|
|
627
626
|
}
|
|
628
|
-
return
|
|
627
|
+
return needsMode || valuesRemaining > 0;
|
|
629
628
|
}
|
|
630
629
|
|
|
631
630
|
/**
|
|
632
631
|
* Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
|
|
633
632
|
* CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
|
|
634
|
-
* payloads pass through verbatim.
|
|
635
|
-
*
|
|
633
|
+
* payloads pass through verbatim. Isolated sequences require no parameter
|
|
634
|
+
* slices or arrays; output is built only at merged boundaries or empty resets
|
|
635
|
+
* that need normalization within an adjacent run.
|
|
636
636
|
*/
|
|
637
637
|
export function coalesceAdjacentSgr(line: string): string {
|
|
638
|
-
if (!SGR_COALESCE_ENABLED
|
|
638
|
+
if (!SGR_COALESCE_ENABLED) return line;
|
|
639
639
|
const n = line.length;
|
|
640
640
|
let out = "";
|
|
641
641
|
let copiedUpto = 0;
|
|
642
|
-
let i =
|
|
643
|
-
while (i
|
|
644
|
-
if (line.charCodeAt(i) !== CC_ESC || line.charCodeAt(i + 1) !== CC_BRACKET) {
|
|
645
|
-
i++;
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
642
|
+
let i = line.indexOf("\x1b[");
|
|
643
|
+
while (i !== -1) {
|
|
648
644
|
// Scan a candidate SGR sequence: ESC [ <params> m.
|
|
649
|
-
let
|
|
650
|
-
|
|
645
|
+
let start = i + 2;
|
|
646
|
+
let j = start;
|
|
647
|
+
let groupTokens = 1;
|
|
648
|
+
while (j < n && isSgrParamByte(line.charCodeAt(j))) {
|
|
649
|
+
const cc = line.charCodeAt(j++);
|
|
650
|
+
if (cc === CC_SEMI || cc === CC_COLON) groupTokens++;
|
|
651
|
+
}
|
|
651
652
|
if (j >= n || line.charCodeAt(j) !== CC_M) {
|
|
652
653
|
// Not an SGR (e.g. cursor move); leave it in the pending region.
|
|
653
|
-
i = j;
|
|
654
|
+
i = line.indexOf("\x1b[", j);
|
|
654
655
|
continue;
|
|
655
656
|
}
|
|
656
|
-
|
|
657
|
-
|
|
657
|
+
|
|
658
|
+
let adjacent = false;
|
|
658
659
|
let k = j + 1;
|
|
659
660
|
while (k < n && line.charCodeAt(k) === CC_ESC && line.charCodeAt(k + 1) === CC_BRACKET) {
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
const nextStart = k + 2;
|
|
662
|
+
let p = nextStart;
|
|
663
|
+
let tokens = 1;
|
|
664
|
+
while (p < n && isSgrParamByte(line.charCodeAt(p))) {
|
|
665
|
+
const cc = line.charCodeAt(p++);
|
|
666
|
+
if (cc === CC_SEMI || cc === CC_COLON) tokens++;
|
|
667
|
+
}
|
|
662
668
|
if (p >= n || line.charCodeAt(p) !== CC_M) break;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
let group = "";
|
|
679
|
-
let groupTokens = 0;
|
|
680
|
-
let groupOpenSafe = true;
|
|
681
|
-
for (let q = 0; q < params.length; q++) {
|
|
682
|
-
const norm = params[q]!.length === 0 ? "0" : params[q]!;
|
|
683
|
-
let tk = 1;
|
|
684
|
-
for (let z = 0; z < norm.length; z++) {
|
|
685
|
-
const cc = norm.charCodeAt(z);
|
|
686
|
-
if (cc === CC_SEMI || cc === CC_COLON) tk++;
|
|
687
|
-
}
|
|
688
|
-
if (groupTokens > 0 && (!groupOpenSafe || groupTokens + tk > MERGE_TOKEN_CAP)) {
|
|
689
|
-
out += `\x1b[${group}m`;
|
|
690
|
-
group = "";
|
|
691
|
-
groupTokens = 0;
|
|
669
|
+
adjacent = true;
|
|
670
|
+
|
|
671
|
+
// Keep the boundary if the preceding list could absorb a missing
|
|
672
|
+
// channel/index, or if merging would overflow the parameter cap.
|
|
673
|
+
// Otherwise replace only `m ESC [` with `;`, keeping the source
|
|
674
|
+
// untouched until a boundary actually merges. Empty lists in an
|
|
675
|
+
// adjacent run normalize to `0`, including at a guarded boundary.
|
|
676
|
+
if (groupTokens + tokens <= MERGE_TOKEN_CAP && !endsWithIncompleteExtendedColor(line, start, j)) {
|
|
677
|
+
out += line.slice(copiedUpto, j) + (start === j ? "0;" : ";");
|
|
678
|
+
copiedUpto = nextStart;
|
|
679
|
+
groupTokens += tokens;
|
|
680
|
+
} else {
|
|
681
|
+
if (start === j) {
|
|
682
|
+
out += `${line.slice(copiedUpto, j)}0`;
|
|
683
|
+
copiedUpto = j;
|
|
692
684
|
}
|
|
693
|
-
|
|
694
|
-
groupTokens += tk;
|
|
695
|
-
groupOpenSafe = !endsWithIncompleteExtendedColor(norm);
|
|
685
|
+
groupTokens = tokens;
|
|
696
686
|
}
|
|
697
|
-
|
|
698
|
-
|
|
687
|
+
start = nextStart;
|
|
688
|
+
j = p;
|
|
689
|
+
k = p + 1;
|
|
690
|
+
}
|
|
691
|
+
if (adjacent && start === j) {
|
|
692
|
+
out += `${line.slice(copiedUpto, j)}0`;
|
|
693
|
+
copiedUpto = j;
|
|
699
694
|
}
|
|
700
|
-
i = k;
|
|
695
|
+
i = line.indexOf("\x1b[", k);
|
|
701
696
|
}
|
|
702
697
|
if (copiedUpto === 0) return line;
|
|
703
698
|
return out + line.slice(copiedUpto);
|
|
@@ -776,6 +771,12 @@ export class TUI extends Container {
|
|
|
776
771
|
// the established differential comparison and resize accounting.
|
|
777
772
|
#providerWindow: string[] = [];
|
|
778
773
|
#providerPreparedRows: PreparedLine[] = [];
|
|
774
|
+
// Rows of the last marker-stripping prepare pass keyed by their stripped
|
|
775
|
+
// raw line, so rows that only moved (a scroll or a history commit shifts
|
|
776
|
+
// every row under the positional sidecar) reuse their preparation. Swapped
|
|
777
|
+
// with the spare each pass, which bounds the memo to one frame of rows.
|
|
778
|
+
#preparedLineMemo = new Map<string, PreparedLine>();
|
|
779
|
+
#preparedLineMemoSpare = new Map<string, PreparedLine>();
|
|
779
780
|
#previousFrameLength = 0;
|
|
780
781
|
#previousWidth = 0;
|
|
781
782
|
#previousHeight = 0;
|
|
@@ -883,7 +884,8 @@ export class TUI extends Container {
|
|
|
883
884
|
// engine paints only the modal on the alt buffer and leaves every
|
|
884
885
|
// normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
|
|
885
886
|
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
886
|
-
// normal screen. #altPreviousLines is the last alt frame,
|
|
887
|
+
// normal screen. #altPreviousLines is the last alt frame, diffed row by row
|
|
888
|
+
// against the next one.
|
|
887
889
|
#altActive = false;
|
|
888
890
|
#mouseTracking: MouseTrackingState = "off";
|
|
889
891
|
/** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
|
|
@@ -1761,9 +1763,13 @@ export class TUI extends Container {
|
|
|
1761
1763
|
(provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
|
|
1762
1764
|
} while (this.#imageBudget.endPass());
|
|
1763
1765
|
const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
|
|
1764
|
-
this.#extractCursorMarkers(viewport);
|
|
1765
1766
|
// The borrowed resize buffer is transient, not a streamable session paint.
|
|
1766
|
-
this.#emitAltFrame(
|
|
1767
|
+
this.#emitAltFrame(
|
|
1768
|
+
this.#prepareLinesArray(viewport, width, this.#altPreparedRows, height, []),
|
|
1769
|
+
width,
|
|
1770
|
+
height,
|
|
1771
|
+
false,
|
|
1772
|
+
);
|
|
1767
1773
|
}
|
|
1768
1774
|
|
|
1769
1775
|
/**
|
|
@@ -2585,30 +2591,6 @@ export class TUI extends Container {
|
|
|
2585
2591
|
return sliceByColumn(result, 0, totalWidth, true);
|
|
2586
2592
|
}
|
|
2587
2593
|
|
|
2588
|
-
/**
|
|
2589
|
-
* Strip every CURSOR_MARKER from the rendered lines (markers are internal
|
|
2590
|
-
* sentinels and must never reach the terminal) and return their positions,
|
|
2591
|
-
* bottom-most first. Callers pick the visible one once the window top is
|
|
2592
|
-
* known.
|
|
2593
|
-
*/
|
|
2594
|
-
#extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
|
|
2595
|
-
const markers: { row: number; col: number }[] = [];
|
|
2596
|
-
for (let row = lines.length - 1; row >= 0; row--) {
|
|
2597
|
-
const line = lines[row];
|
|
2598
|
-
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
2599
|
-
if (markerIndex === -1) continue;
|
|
2600
|
-
const beforeMarker = line.slice(0, markerIndex);
|
|
2601
|
-
markers.push({ row, col: visibleWidth(beforeMarker) });
|
|
2602
|
-
let stripped = line;
|
|
2603
|
-
while (markerIndex !== -1) {
|
|
2604
|
-
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
2605
|
-
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
2606
|
-
}
|
|
2607
|
-
lines[row] = stripped;
|
|
2608
|
-
}
|
|
2609
|
-
return markers;
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
2594
|
/**
|
|
2613
2595
|
* Rewrite a Kitty direct-placement line for the viewport row it is written
|
|
2614
2596
|
* at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
|
|
@@ -2801,9 +2783,11 @@ export class TUI extends Container {
|
|
|
2801
2783
|
replayViewportRows = moved;
|
|
2802
2784
|
}
|
|
2803
2785
|
}
|
|
2804
|
-
|
|
2805
|
-
|
|
2786
|
+
// History first: it reuses the previous viewport's rows by content, and
|
|
2787
|
+
// the viewport pass replaces that memo with its own rows.
|
|
2806
2788
|
const preparedHistory = this.#prepareLinesArray(historyRows, width);
|
|
2789
|
+
const markers: { row: number; col: number }[] = [];
|
|
2790
|
+
const prepared = this.#prepareLinesArray(viewport, width, this.#providerPreparedRows, viewport.length, markers);
|
|
2807
2791
|
const rows = prepared.lines.length;
|
|
2808
2792
|
// Destructive reset (session replace, /tree, explicit clear, or a settled
|
|
2809
2793
|
// resize in rebuild mode): erase native history and the viewport,
|
|
@@ -2867,16 +2851,18 @@ export class TUI extends Container {
|
|
|
2867
2851
|
this.#providerWindow.length > 0;
|
|
2868
2852
|
if (diffable) {
|
|
2869
2853
|
for (let index = 0; index < rows; index++) {
|
|
2870
|
-
const previous = this.#providerPreparedRows[index];
|
|
2871
|
-
const current = prepared.rows[index]!;
|
|
2872
2854
|
if (
|
|
2873
|
-
this.#
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2855
|
+
!this.#rowNeedsRewrite(
|
|
2856
|
+
this.#providerWindow,
|
|
2857
|
+
this.#providerPreparedRows,
|
|
2858
|
+
prepared.lines,
|
|
2859
|
+
prepared.rows,
|
|
2860
|
+
index,
|
|
2861
|
+
)
|
|
2877
2862
|
) {
|
|
2878
2863
|
continue;
|
|
2879
2864
|
}
|
|
2865
|
+
const current = prepared.rows[index]!;
|
|
2880
2866
|
buffer += `\x1b[${newTop + index + 1};1H${this.#lineRewriteSequence(
|
|
2881
2867
|
current,
|
|
2882
2868
|
width,
|
|
@@ -3145,13 +3131,23 @@ export class TUI extends Container {
|
|
|
3145
3131
|
* Prepare one string projection plus its structured write sidecar. A prior
|
|
3146
3132
|
* sidecar entry is reusable only under identical raw content, width, width
|
|
3147
3133
|
* configuration, and image protocol; those are every mutable input to
|
|
3148
|
-
* normalization, fitting, classification, and terminal coalescing.
|
|
3134
|
+
* normalization, fitting, classification, and terminal coalescing. The same
|
|
3135
|
+
* row at the same index is checked first, then the previous stripping pass's
|
|
3136
|
+
* rows by content, so a scrolled row is not re-prepared.
|
|
3137
|
+
*
|
|
3138
|
+
* With `markers`, every CURSOR_MARKER is stripped (markers are internal
|
|
3139
|
+
* sentinels and must never reach the terminal) and its position recorded,
|
|
3140
|
+
* bottom-most first; callers pick the visible one once the window top is
|
|
3141
|
+
* known. Without it (history rows) lines are prepared verbatim. Every
|
|
3142
|
+
* reusable entry holds a stripped raw line, so a row that matches one
|
|
3143
|
+
* carries no marker and skips the marker scan.
|
|
3149
3144
|
*/
|
|
3150
3145
|
#prepareLinesArray(
|
|
3151
3146
|
lines: readonly string[],
|
|
3152
3147
|
width: number,
|
|
3153
3148
|
previous: readonly PreparedLine[] = [],
|
|
3154
3149
|
length = lines.length,
|
|
3150
|
+
markers?: { row: number; col: number }[],
|
|
3155
3151
|
): PreparedLines {
|
|
3156
3152
|
// oxlint-disable-next-line unicorn/no-new-array -- render-frame length preallocation
|
|
3157
3153
|
const prepared: string[] = new Array(length);
|
|
@@ -3159,23 +3155,61 @@ export class TUI extends Container {
|
|
|
3159
3155
|
const rows: PreparedLine[] = new Array(length);
|
|
3160
3156
|
const widthEpoch = getWidthConfigEpoch();
|
|
3161
3157
|
const imageProtocol = TERMINAL.imageProtocol;
|
|
3158
|
+
const memo = this.#preparedLineMemo;
|
|
3159
|
+
// Only stripping passes index their rows: a verbatim history row may keep
|
|
3160
|
+
// a marker, and reusing it for a viewport row would leak that marker.
|
|
3161
|
+
const nextMemo = markers === undefined ? undefined : this.#preparedLineMemoSpare;
|
|
3162
3162
|
for (let i = 0; i < length; i++) {
|
|
3163
|
-
const
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3163
|
+
const source = lines[i] ?? "";
|
|
3164
|
+
let row = this.#reusablePreparedLine(previous[i], memo, source, width, widthEpoch, imageProtocol);
|
|
3165
|
+
if (row === undefined) {
|
|
3166
|
+
let raw = source;
|
|
3167
|
+
if (markers !== undefined) {
|
|
3168
|
+
let markerIndex = source.indexOf(CURSOR_MARKER);
|
|
3169
|
+
if (markerIndex !== -1) {
|
|
3170
|
+
markers.push({ row: i, col: visibleWidth(source.slice(0, markerIndex)) });
|
|
3171
|
+
// Resume the search just before the splice so a marker that the
|
|
3172
|
+
// removal itself joins together is stripped too; reusable rows
|
|
3173
|
+
// must stay marker-free.
|
|
3174
|
+
while (markerIndex !== -1) {
|
|
3175
|
+
raw = raw.slice(0, markerIndex) + raw.slice(markerIndex + CURSOR_MARKER.length);
|
|
3176
|
+
markerIndex = raw.indexOf(CURSOR_MARKER, Math.max(0, markerIndex - CURSOR_MARKER.length + 1));
|
|
3177
|
+
}
|
|
3178
|
+
row = this.#reusablePreparedLine(previous[i], memo, raw, width, widthEpoch, imageProtocol);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
row ??= this.#prepareLine(raw, width, widthEpoch, imageProtocol);
|
|
3182
|
+
}
|
|
3183
|
+
nextMemo?.set(row.raw, row);
|
|
3173
3184
|
prepared[i] = row.line;
|
|
3174
3185
|
rows[i] = row;
|
|
3175
3186
|
}
|
|
3187
|
+
if (nextMemo !== undefined) {
|
|
3188
|
+
memo.clear();
|
|
3189
|
+
this.#preparedLineMemo = nextMemo;
|
|
3190
|
+
this.#preparedLineMemoSpare = memo;
|
|
3191
|
+
markers?.reverse();
|
|
3192
|
+
}
|
|
3176
3193
|
return { lines: prepared, rows };
|
|
3177
3194
|
}
|
|
3178
3195
|
|
|
3196
|
+
#reusablePreparedLine(
|
|
3197
|
+
positional: PreparedLine | undefined,
|
|
3198
|
+
memo: ReadonlyMap<string, PreparedLine>,
|
|
3199
|
+
raw: string,
|
|
3200
|
+
width: number,
|
|
3201
|
+
widthEpoch: number,
|
|
3202
|
+
imageProtocol: ImageProtocol | null,
|
|
3203
|
+
): PreparedLine | undefined {
|
|
3204
|
+
const cached = positional !== undefined && positional.raw === raw ? positional : memo.get(raw);
|
|
3205
|
+
return cached !== undefined &&
|
|
3206
|
+
cached.width === width &&
|
|
3207
|
+
cached.widthEpoch === widthEpoch &&
|
|
3208
|
+
cached.imageProtocol === imageProtocol
|
|
3209
|
+
? cached
|
|
3210
|
+
: undefined;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3179
3213
|
#prepareLine(raw: string, width: number, widthEpoch: number, imageProtocol: ImageProtocol | null): PreparedLine {
|
|
3180
3214
|
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
3181
3215
|
const maxSourceLength = Math.min(
|
|
@@ -3440,6 +3474,29 @@ export class TUI extends Container {
|
|
|
3440
3474
|
return visibleWidth(above);
|
|
3441
3475
|
}
|
|
3442
3476
|
|
|
3477
|
+
/**
|
|
3478
|
+
* Whether screen row `index` must be rewritten to turn the previously painted
|
|
3479
|
+
* frame into this one: its line, or the preparation it was painted with,
|
|
3480
|
+
* changed.
|
|
3481
|
+
*/
|
|
3482
|
+
#rowNeedsRewrite(
|
|
3483
|
+
previousLines: readonly string[],
|
|
3484
|
+
previousRows: readonly PreparedLine[],
|
|
3485
|
+
lines: readonly string[],
|
|
3486
|
+
rows: readonly PreparedLine[],
|
|
3487
|
+
index: number,
|
|
3488
|
+
): boolean {
|
|
3489
|
+
const previous = previousRows[index];
|
|
3490
|
+
const current = rows[index]!;
|
|
3491
|
+
return (
|
|
3492
|
+
previousLines[index] !== lines[index] ||
|
|
3493
|
+
previous === undefined ||
|
|
3494
|
+
previous.width !== current.width ||
|
|
3495
|
+
previous.widthEpoch !== current.widthEpoch ||
|
|
3496
|
+
previous.imageProtocol !== current.imageProtocol
|
|
3497
|
+
);
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3443
3500
|
#lineRewriteSequence(
|
|
3444
3501
|
line: PreparedLine,
|
|
3445
3502
|
width: number,
|
|
@@ -3543,15 +3600,16 @@ export class TUI extends Container {
|
|
|
3543
3600
|
this.#imageBudget.beginPass(false, true);
|
|
3544
3601
|
lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
3545
3602
|
} while (this.#imageBudget.endPass());
|
|
3546
|
-
this.#
|
|
3547
|
-
const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height);
|
|
3603
|
+
const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
|
|
3548
3604
|
this.#emitAltFrame(prepared, width, height, true);
|
|
3549
3605
|
}
|
|
3550
3606
|
|
|
3551
3607
|
/**
|
|
3552
|
-
*
|
|
3553
|
-
*
|
|
3554
|
-
*
|
|
3608
|
+
* Paint a frame on the alt buffer: only the rows that changed since the
|
|
3609
|
+
* previous frame, or every row when the height changed, a repaint is forced,
|
|
3610
|
+
* or a changed frame holds OSC 66 text before or after. Emits only
|
|
3611
|
+
* sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
|
|
3612
|
+
* any native-scrollback byte. The hardware cursor stays hidden here.
|
|
3555
3613
|
*/
|
|
3556
3614
|
#emitAltFrame(prepared: PreparedLines, width: number, height: number, notifyPaint: boolean): void {
|
|
3557
3615
|
// The pass that composed this frame ran with `altScreen`, so the normal
|
|
@@ -3573,38 +3631,34 @@ export class TUI extends Container {
|
|
|
3573
3631
|
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
3574
3632
|
this.terminal.write(transmitBuffer);
|
|
3575
3633
|
}
|
|
3576
|
-
//
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
3579
|
-
//
|
|
3634
|
+
// A forced repaint (resetDisplay, requestRender(true)) rewrites every row
|
|
3635
|
+
// even when the cached frame is byte-identical: the redraw gesture must
|
|
3636
|
+
// repair a corrupted modal. So does a changed frame with OSC 66 text in
|
|
3637
|
+
// it, before or after: a scaled glyph spans the rows below its own and the
|
|
3638
|
+
// terminal drops it when any of them is written, so those rows are not
|
|
3639
|
+
// independent. Otherwise rewrite only the rows that changed (a keystroke
|
|
3640
|
+
// in a modal touches a row or two), and skip an identical frame entirely.
|
|
3580
3641
|
const force = this.#forceViewportRepaintOnNextRender;
|
|
3581
3642
|
this.#forceViewportRepaintOnNextRender = false;
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
previous.width !== current.width ||
|
|
3591
|
-
previous.widthEpoch !== current.widthEpoch ||
|
|
3592
|
-
previous.imageProtocol !== current.imageProtocol
|
|
3593
|
-
) {
|
|
3594
|
-
same = false;
|
|
3595
|
-
break;
|
|
3596
|
-
}
|
|
3597
|
-
}
|
|
3598
|
-
if (same) {
|
|
3599
|
-
this.#altPreviousLines = prepared.lines;
|
|
3600
|
-
this.#altPreparedRows = prepared.rows;
|
|
3601
|
-
return;
|
|
3602
|
-
}
|
|
3603
|
-
}
|
|
3604
|
-
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3643
|
+
const full =
|
|
3644
|
+
force ||
|
|
3645
|
+
this.#altPreviousLines.length !== height ||
|
|
3646
|
+
((this.#altPreviousLines.some(isOsc66Line) || prepared.lines.some(isOsc66Line)) &&
|
|
3647
|
+
prepared.rows.some((_row, r) =>
|
|
3648
|
+
this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
|
|
3649
|
+
));
|
|
3650
|
+
let rowsBuffer = "";
|
|
3605
3651
|
for (let r = 0; r < height; r++) {
|
|
3606
|
-
if (
|
|
3607
|
-
|
|
3652
|
+
if (full) {
|
|
3653
|
+
if (r > 0) rowsBuffer += "\n";
|
|
3654
|
+
} else if (
|
|
3655
|
+
this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r)
|
|
3656
|
+
) {
|
|
3657
|
+
rowsBuffer += `\x1b[${r + 1};1H`;
|
|
3658
|
+
} else {
|
|
3659
|
+
continue;
|
|
3660
|
+
}
|
|
3661
|
+
rowsBuffer += this.#lineRewriteSequence(
|
|
3608
3662
|
prepared.rows[r]!,
|
|
3609
3663
|
width,
|
|
3610
3664
|
r,
|
|
@@ -3613,10 +3667,10 @@ export class TUI extends Container {
|
|
|
3613
3667
|
this.#osc66SpacerGlyphWidth(prepared.lines, r),
|
|
3614
3668
|
);
|
|
3615
3669
|
}
|
|
3616
|
-
buffer += this.#paintEndSequence;
|
|
3617
|
-
this.terminal.write(buffer);
|
|
3618
3670
|
this.#altPreviousLines = prepared.lines;
|
|
3619
3671
|
this.#altPreparedRows = prepared.rows;
|
|
3672
|
+
if (rowsBuffer === "") return;
|
|
3673
|
+
this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
|
|
3620
3674
|
this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
|
|
3621
3675
|
if (notifyPaint) {
|
|
3622
3676
|
this.#notifyPaint({
|
|
@@ -3628,6 +3682,6 @@ export class TUI extends Container {
|
|
|
3628
3682
|
rows: height,
|
|
3629
3683
|
});
|
|
3630
3684
|
}
|
|
3631
|
-
this.#fullRedrawCount += 1;
|
|
3685
|
+
if (full) this.#fullRedrawCount += 1;
|
|
3632
3686
|
}
|
|
3633
3687
|
}
|
package/src/utils.ts
CHANGED
|
@@ -241,6 +241,9 @@ const OSC66_PREFIX = "\x1b]66;";
|
|
|
241
241
|
const APC_SPAN_REGEX = /\x1b_[\s\S]*?(?:\x07|\x1b\\)/g;
|
|
242
242
|
const APC_PREFIX = "\x1b_";
|
|
243
243
|
const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
|
|
244
|
+
// Keep native escape parsing: a JS SGR parser costs more than Bun's scanner.
|
|
245
|
+
// Test Jamo separately from the ASCII correction markers to keep scans cheap.
|
|
246
|
+
const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
|
|
244
247
|
|
|
245
248
|
// Pin Bun.stringWidth semantics to the native width engine and guard against Bun
|
|
246
249
|
// default drift: strip ANSI/OSC (don't count escape bytes) and treat
|
|
@@ -337,6 +340,18 @@ export function visibleWidth(str: string): number {
|
|
|
337
340
|
return str.length;
|
|
338
341
|
}
|
|
339
342
|
|
|
343
|
+
// The extra gate pays for itself on long uncached lines, not short cache
|
|
344
|
+
// misses. APC and OSC 66 still need their existing payload corrections.
|
|
345
|
+
if (
|
|
346
|
+
!cacheable &&
|
|
347
|
+
!str.includes("\t") &&
|
|
348
|
+
!HANGUL_COMPAT_JAMO_REGEX.test(str) &&
|
|
349
|
+
!str.includes(APC_PREFIX) &&
|
|
350
|
+
!str.includes(OSC66_PREFIX)
|
|
351
|
+
) {
|
|
352
|
+
return Bun.stringWidth(str, STRING_WIDTH_OPTS);
|
|
353
|
+
}
|
|
354
|
+
|
|
340
355
|
let tabCount = 0;
|
|
341
356
|
let compatibilityJamoCount = 0;
|
|
342
357
|
let fillerCount = 0;
|