@linxiraos/pi-tui 1.1.4 → 1.1.5
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 +2 -4
- package/dist/types/components/loader.d.ts +1 -1
- package/dist/types/components/markdown.d.ts +14 -0
- package/dist/types/tui.d.ts +12 -3
- package/package.json +3 -3
- package/src/components/loader.ts +10 -3
- package/src/components/markdown.ts +371 -1
- package/src/tui.ts +328 -54
package/CHANGELOG.md
CHANGED
|
@@ -14,7 +14,7 @@ export declare class Loader extends Text {
|
|
|
14
14
|
private spinnerColorFn;
|
|
15
15
|
private messageColorFn;
|
|
16
16
|
private message;
|
|
17
|
-
constructor(ui: TUI, spinnerColorFn: ColorFn, messageColorFn: LoaderMessageColorFn, message?: string, spinnerFrames?: string[]);
|
|
17
|
+
constructor(ui: TUI, spinnerColorFn: ColorFn, messageColorFn: LoaderMessageColorFn, message?: string | (() => string), spinnerFrames?: string[]);
|
|
18
18
|
render(width: number): readonly string[];
|
|
19
19
|
start(): void;
|
|
20
20
|
stop(): void;
|
|
@@ -6,6 +6,14 @@ export declare function mathStartIndex(src: string): number | undefined;
|
|
|
6
6
|
export declare function autolinkSchemeScanIndex(src: string): number | undefined;
|
|
7
7
|
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
8
8
|
export declare function urlTokenPossible(src: string): boolean;
|
|
9
|
+
/** @internal exported for tests — counts fast-tail splice frames. A future
|
|
10
|
+
* regression that silently disarms the fast path (e.g. an over-broad gate)
|
|
11
|
+
* leaves byte-identity intact but drops the counter to zero. */
|
|
12
|
+
export declare let fastTailSplices: number;
|
|
13
|
+
/** @internal exported for tests — resets the splice counter. */
|
|
14
|
+
export declare function resetFastTailSplices(): void;
|
|
15
|
+
/** @internal exported for tests — the grown-line-start block-kind gate. */
|
|
16
|
+
export declare function fastLineStartHazard(grownLine: string): boolean;
|
|
9
17
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
10
18
|
export declare function clearRenderCache(): void;
|
|
11
19
|
/**
|
|
@@ -75,6 +83,12 @@ export declare class Markdown implements Component {
|
|
|
75
83
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
76
84
|
setText(text: string): boolean;
|
|
77
85
|
invalidate(): void;
|
|
86
|
+
/**
|
|
87
|
+
* Width-independent source prefix of the last render ending at a frozen
|
|
88
|
+
* Markdown block boundary. Only meaningful while streaming (transient
|
|
89
|
+
* render cache on); grows monotonically under append-only `setText`.
|
|
90
|
+
*/
|
|
91
|
+
getLastRenderStableText(): string;
|
|
78
92
|
get transientRenderCache(): boolean;
|
|
79
93
|
set transientRenderCache(value: boolean);
|
|
80
94
|
render(width: number): readonly string[];
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -23,12 +23,19 @@ export interface ViewportSize {
|
|
|
23
23
|
readonly columns: number;
|
|
24
24
|
readonly rows: number;
|
|
25
25
|
}
|
|
26
|
-
/** Immutable
|
|
26
|
+
/** Immutable append or complete replay offered until the terminal accepts this identifier. */
|
|
27
27
|
export interface HistoryBatch {
|
|
28
28
|
readonly id: number;
|
|
29
29
|
readonly rows: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* `append` (the default) adds finalized or naturally emitted rows. `replay`
|
|
32
|
+
* is the complete logical ledger; the writer bottom-splits it against the
|
|
33
|
+
* leading blank viewport and serializes the remainder plus final viewport in
|
|
34
|
+
* one synchronous terminal write.
|
|
35
|
+
*/
|
|
36
|
+
readonly kind?: "append" | "replay";
|
|
30
37
|
}
|
|
31
|
-
/** One history append
|
|
38
|
+
/** One history append or complete replay plus the mutable viewport for a terminal frame. */
|
|
32
39
|
export interface TerminalFramePlan {
|
|
33
40
|
readonly history?: HistoryBatch;
|
|
34
41
|
readonly viewport: readonly string[];
|
|
@@ -40,7 +47,9 @@ export interface TerminalFrameProvider {
|
|
|
40
47
|
/** Full semantic viewport used only on the transient resize buffer. */
|
|
41
48
|
renderResizeFrame?(viewport: ViewportSize): readonly string[];
|
|
42
49
|
/** Re-offer finalized history after a display reset or resize replay. */
|
|
43
|
-
|
|
50
|
+
beginHistoryReplay?(): void;
|
|
51
|
+
/** Force every currently eligible finalized prefix to retire before stop. */
|
|
52
|
+
beginHistoryFlush?(): void;
|
|
44
53
|
}
|
|
45
54
|
export interface TUIStartOptions {
|
|
46
55
|
/** Clear saved native scrollback before the first paint. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-tui",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.5",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@linxiraos/pi-natives": "1.1.
|
|
41
|
-
"@linxiraos/pi-utils": "1.1.
|
|
40
|
+
"@linxiraos/pi-natives": "1.1.5",
|
|
41
|
+
"@linxiraos/pi-utils": "1.1.5"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/components/loader.ts
CHANGED
|
@@ -32,7 +32,7 @@ export class Loader extends Text {
|
|
|
32
32
|
ui: TUI,
|
|
33
33
|
private spinnerColorFn: ColorFn,
|
|
34
34
|
private messageColorFn: LoaderMessageColorFn,
|
|
35
|
-
private message: string = "Loading...",
|
|
35
|
+
private message: string | (() => string) = "Loading...",
|
|
36
36
|
spinnerFrames?: string[],
|
|
37
37
|
) {
|
|
38
38
|
super("", 1, 0);
|
|
@@ -149,11 +149,18 @@ export class Loader extends Text {
|
|
|
149
149
|
}, delayMs);
|
|
150
150
|
this.#intervalId = timer;
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
#resolveMessage(): string {
|
|
153
|
+
return typeof this.message === "function" ? this.message() : this.message;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Re-wrap the underlying Text only when its message or frame width changes.
|
|
157
|
+
* When {@link message} is a function it is re-evaluated on every spinner
|
|
158
|
+
* tick, so a dynamic label (e.g. a live countdown) advances in sync with
|
|
159
|
+
* the glyph instead of freezing on the initial value. */
|
|
153
160
|
#syncText(): boolean {
|
|
154
161
|
const layoutFrame = this.#layoutFrames[this.#currentFrame];
|
|
155
162
|
this.#layoutFrame = layoutFrame;
|
|
156
|
-
return this.setText(`${layoutFrame} ${this
|
|
163
|
+
return this.setText(`${layoutFrame} ${this.#resolveMessage()}`);
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
#requestPaint() {
|
|
@@ -953,12 +953,167 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
|
|
|
953
953
|
return Math.max(1, size);
|
|
954
954
|
}
|
|
955
955
|
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
// Fast-tail (B+) hazard gates
|
|
958
|
+
// ---------------------------------------------------------------------------
|
|
959
|
+
// Tier-1 eligibility: the appended delta is "markdown-inert" — it cannot open
|
|
960
|
+
// or close an inline token, change block structure, or shift a swatch
|
|
961
|
+
// boundary. A delta carrying a marker is re-lexed through the REAL inline
|
|
962
|
+
// pipeline so self-contained marker pairs render styled — exactly what a full
|
|
963
|
+
// re-lex of the grown row produces. `_` is included but narrowed: an
|
|
964
|
+
// intraword `_` is literal per CommonMark flanking rules, so only FLANKED
|
|
965
|
+
// underscores disarm (FAST_ROW_UNDERSCORE_RE on the row text, plus the
|
|
966
|
+
// delta-edge trailingDelimiterSeamHazard check).
|
|
967
|
+
const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
|
|
968
|
+
|
|
969
|
+
// Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
|
|
970
|
+
// trims it; appending a char moves the trim boundary), a trailing backslash
|
|
971
|
+
// (it can become an escape once the delta supplies the next char — the `\\`
|
|
972
|
+
// clause covers that escape-completion hazard), or a full/partial hex swatch
|
|
973
|
+
// run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
|
|
974
|
+
const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
|
|
975
|
+
|
|
976
|
+
// A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
|
|
977
|
+
// across the seam (delta hex digits are inert).
|
|
978
|
+
const FAST_SWATCH_SEAM_RE = /#[0-9a-fA-F]{0,2}$/;
|
|
979
|
+
|
|
980
|
+
// A partial HTML entity at the seam (`&am` + delta `p;`) OR a complete
|
|
981
|
+
// numeric entity (`#`, `😀`) — which decodes to `#`, a swatch
|
|
982
|
+
// lead — would normalize to different bytes than the plain concat.
|
|
983
|
+
const FAST_ENTITY_SEAM_RE = /&(?:[A-Za-z0-9#]{0,31}|#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};)$/;
|
|
984
|
+
|
|
985
|
+
// A bare URL/email anywhere in the delta or across the seam (a URL the regex
|
|
986
|
+
// cut at a trailing delimiter can re-link once the delta supplies more chars;
|
|
987
|
+
// a protocol head ending at the seam completes in the delta) makes the full
|
|
988
|
+
// re-lex autolink while the plain concat would not.
|
|
989
|
+
const FAST_URL_ANYWHERE_RE = /(?:https?|ftp):\/\/|www\.[A-Za-z0-9]|[A-Za-z0-9._%+-]+@/i;
|
|
990
|
+
|
|
991
|
+
// A bare-URL/email PREFIX may end at the seam and complete in the delta
|
|
992
|
+
// (`ht` + `tps://x`, `foo@` + `bar.com`).
|
|
993
|
+
const FAST_URL_PREFIX_SEAM_RE = /(?:https?|ftp):?\/{0,2}$|www\.$|[A-Za-z0-9._+-]+@[A-Za-z0-9._+-]*$/;
|
|
994
|
+
|
|
995
|
+
// Inline-markup delimiters that survive into rendered output as LITERAL text
|
|
996
|
+
// when unpaired. The fast path detects open constructs by walking the REAL
|
|
997
|
+
// inline token stream (capture) and the delta's inline token stream (frame):
|
|
998
|
+
// any top-level `text` token still carrying one of these bytes holds an open
|
|
999
|
+
// delimiter, so a later delta could close it and a full re-lex would restyle
|
|
1000
|
+
// the seam. Closed pairs tokenize into styled tokens and never appear here.
|
|
1001
|
+
// `_` is excluded (intraword `_` is inert); a FLANKED underscore is caught
|
|
1002
|
+
// by FAST_ROW_UNDERSCORE_RE on the raw text (SGR bytes precede text, so word
|
|
1003
|
+
// boundaries are invisible after styling).
|
|
1004
|
+
const FAST_LITERAL_MARKER_RE = /[*~`[\]<>()$&#]/;
|
|
1005
|
+
|
|
1006
|
+
// A flanking underscore (start-of-line or preceded by a non-word char) can
|
|
1007
|
+
// open an emphasis that a future delta closes. Only flanked `_` is a
|
|
1008
|
+
// delimiter; intraword `_` (a_b) is literal.
|
|
1009
|
+
const FAST_ROW_UNDERSCORE_RE = /(?:^|[^\w])_/;
|
|
1010
|
+
// Two distinct CommonMark word-char notions drive the seam re-flank checks.
|
|
1011
|
+
// For the `_`-underscore seam, "word char" = ASCII `\w` (which includes `_`)
|
|
1012
|
+
// plus Unicode letters/numbers — `[\w\p{L}\p{N}]`. `\w` alone missed a row
|
|
1013
|
+
// ending in a Unicode letter (`é`); CM's char class `[^\s\p{P}\p{S}]` would
|
|
1014
|
+
// wrongly treat `_` (\p{Pc}) as a word char and break the `_..._` intraword
|
|
1015
|
+
// gate. For `*`/`~` closing emphasis, marked's flanking test uses the full
|
|
1016
|
+
// class `[^\s\p{P}\p{S}]` (which covers format/combining marks like U+200C and
|
|
1017
|
+
// U+0301), so branch 2 must use that wider class.
|
|
1018
|
+
const FAST_UNDERSCORE_WORD_AT_END_RE = /[\w\p{L}\p{N}]$/u;
|
|
1019
|
+
const FAST_CMARK_WORD_AT_START_RE = /^[^\s\p{P}\p{S}]/u;
|
|
1020
|
+
// A GFM table delimiter row lets a preceding pipe-header line flip into a
|
|
1021
|
+
// table when a future inert delta completes it — even a marker-free delta
|
|
1022
|
+
// (`| col_a | col_b |\n| --` + `--- | -`). The cold render then re-wraps and
|
|
1023
|
+
// restyles the header, so the splice must disarm. The gate runs on the GROWN
|
|
1024
|
+
// last line (`recipe.rowRaw`'s last line + deltaTabs) in render().
|
|
1025
|
+
const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+\s*(?:\|[\s:]*-+\s*)+)\|?\s*$/;
|
|
1026
|
+
|
|
1027
|
+
// A paragraph's LAST line can complete into a different block kind under an
|
|
1028
|
+
// inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
|
|
1029
|
+
// when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
|
|
1030
|
+
const FAST_LINE_START_HAZARD_RE =
|
|
1031
|
+
// `-` is placed LAST so it is a literal, not a range bound. The other
|
|
1032
|
+
// chars are in ASCENDING code-point order (no reversed ranges that
|
|
1033
|
+
// rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
|
|
1034
|
+
/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
|
|
1035
|
+
/** @internal exported for tests — counts fast-tail splice frames. A future
|
|
1036
|
+
* regression that silently disarms the fast path (e.g. an over-broad gate)
|
|
1037
|
+
* leaves byte-identity intact but drops the counter to zero. */
|
|
1038
|
+
export let fastTailSplices = 0;
|
|
1039
|
+
/** @internal exported for tests — resets the splice counter. */
|
|
1040
|
+
export function resetFastTailSplices(): void {
|
|
1041
|
+
fastTailSplices = 0;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** @internal exported for tests — the grown-line-start block-kind gate. */
|
|
1045
|
+
export function fastLineStartHazard(grownLine: string): boolean {
|
|
1046
|
+
return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Seam hazards between the captured raw row tail and the delta: the row must
|
|
1050
|
+
* not end in a wrap-trim, escape, swatch, entity, or URL/email prefix, and
|
|
1051
|
+
* must hold no unbalanced bracket an inert delta could close into a link. */
|
|
1052
|
+
function fastTailSeamSafe(raw: string): boolean {
|
|
1053
|
+
if (FAST_RUN_END_RE.test(raw)) return false;
|
|
1054
|
+
if (FAST_SWATCH_SEAM_RE.test(raw)) return false;
|
|
1055
|
+
if (FAST_ENTITY_SEAM_RE.test(raw)) return false;
|
|
1056
|
+
// Entities decode before swatch/whitespace detection (`#ab` → `#ab`,
|
|
1057
|
+
// ` ` → ` `): scan the DECODED tail so an entity-indirected swatch
|
|
1058
|
+
// lead OR a decoded trailing space (wrap-trim boundary shifts) disarms.
|
|
1059
|
+
// Autolinks are lex-time on RAW text, so URL prefix stays raw.
|
|
1060
|
+
const rawTail = raw.length > 32 ? raw.slice(-32) : raw;
|
|
1061
|
+
const decodedTail = normalizeHtmlEntitiesForTerminal(rawTail);
|
|
1062
|
+
if (decodedTail !== rawTail && (FAST_RUN_END_RE.test(decodedTail) || FAST_SWATCH_SEAM_RE.test(decodedTail))) {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
if (FAST_URL_PREFIX_SEAM_RE.test(raw)) return false;
|
|
1066
|
+
if (raw.endsWith("]") || raw.lastIndexOf("[") > raw.lastIndexOf("]")) return false;
|
|
1067
|
+
if (raw.lastIndexOf("(") > raw.lastIndexOf(")") || raw.lastIndexOf("<") > raw.lastIndexOf(">")) return false;
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
// Fast-tail (B+) recipe: captured frame state for the next inert-delta splice.
|
|
1071
|
+
interface FastTailRecipe {
|
|
1072
|
+
readonly lines: readonly string[]; // frame rows at capture
|
|
1073
|
+
readonly source: string; // raw #text at capture (append-only predicate)
|
|
1074
|
+
readonly width: number; // contentWidth at capture
|
|
1075
|
+
rowText: string; // RENDERED last wrap-output row (re-wrap input)
|
|
1076
|
+
rowRaw: string; // RAW tail source backing that row (seam scan)
|
|
1077
|
+
rowStart: number; // result[] index of the replaced row
|
|
1078
|
+
rowEnd: number; // exclusive result[] index
|
|
1079
|
+
readonly signature: RenderSignature; // full render signature at capture (bgColor etc.)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** True when the inline token stream holds an OPEN construct: a `text` token
|
|
1083
|
+
* that still carries a literal delimiter (an unpaired `*`/`` ` ``/`[`/… or a
|
|
1084
|
+
* flanking `_`), or raw HTML. Closed constructs are styled tokens whose
|
|
1085
|
+
* delimiters are absent from their text. An open means a FUTURE delta could
|
|
1086
|
+
* close it — the fast path disarms so the splice always matches a full lex. */
|
|
1087
|
+
function inlineHasOpen(tokens: readonly Token[]): boolean {
|
|
1088
|
+
for (const token of tokens) {
|
|
1089
|
+
if (isMathToken(token)) continue;
|
|
1090
|
+
if (token.type === "codespan") continue; // styled leaf; its content cannot re-pair
|
|
1091
|
+
if (token.type === "html") return true; // raw HTML — conservative
|
|
1092
|
+
if (token.type === "text") {
|
|
1093
|
+
const text = "text" in token && typeof token.text === "string" ? token.text : "";
|
|
1094
|
+
if (FAST_LITERAL_MARKER_RE.test(text) || FAST_ROW_UNDERSCORE_RE.test(text)) return true;
|
|
1095
|
+
}
|
|
1096
|
+
if ("tokens" in token && Array.isArray(token.tokens)) {
|
|
1097
|
+
if (inlineHasOpen(token.tokens as Token[])) return true;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Isolated inline lex of a same-line delta. A single-line delta has no block
|
|
1104
|
+
* structure, so the isolated inline pass equals the full lex's inline pass
|
|
1105
|
+
* (marked's paragraph tokens run the same `inlineTokens` entry point). */
|
|
1106
|
+
function lexInlineTokens(text: string): Token[] {
|
|
1107
|
+
return new Lexer(markdownParser.defaults).inlineTokens(text);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
956
1110
|
// A reference-link definition (`[label]: dest`) resolves across the whole
|
|
957
1111
|
// document, so a split lex cannot reproduce it — disable the streaming fast path
|
|
958
1112
|
// when one is present (rare in streamed output). The label may contain
|
|
959
1113
|
// backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
|
|
960
1114
|
// over-matching is safe (it only costs the fast path), under-matching is not.
|
|
961
|
-
const
|
|
1115
|
+
const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
|
|
1116
|
+
const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
|
|
962
1117
|
|
|
963
1118
|
// marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
|
|
964
1119
|
// blank lines only when the remaining source matches
|
|
@@ -1620,6 +1775,9 @@ export class Markdown implements Component {
|
|
|
1620
1775
|
#renderingStablePrefix = false;
|
|
1621
1776
|
#streamingHighlightCache?: StreamingHighlightCache;
|
|
1622
1777
|
#activeRenderSignature?: RenderSignature;
|
|
1778
|
+
#fastTail?: FastTailRecipe; // undefined = disarmed
|
|
1779
|
+
// B+ capture plumbing: #renderContentLines records the last rendered paragraph row.
|
|
1780
|
+
#lastTailCapture?: { kind: "paragraph"; open: boolean; rowInput: string; rowRaw: string };
|
|
1623
1781
|
#ignoreTight = false;
|
|
1624
1782
|
setIgnoreTight(ignore: boolean): this {
|
|
1625
1783
|
this.#ignoreTight = ignore;
|
|
@@ -1694,6 +1852,9 @@ export class Markdown implements Component {
|
|
|
1694
1852
|
this.#streamPrefixTokens = undefined;
|
|
1695
1853
|
this.#streamPrefixLineCache = undefined;
|
|
1696
1854
|
this.#tailRowCache = undefined;
|
|
1855
|
+
// B+: the captured fast-path rows index the replaced content — drop
|
|
1856
|
+
// the recipe so a fresh stream cannot splice onto stale rows.
|
|
1857
|
+
this.#fastTail = undefined;
|
|
1697
1858
|
}
|
|
1698
1859
|
this.invalidate();
|
|
1699
1860
|
return true;
|
|
@@ -1704,6 +1865,16 @@ export class Markdown implements Component {
|
|
|
1704
1865
|
this.#cachedWidth = undefined;
|
|
1705
1866
|
this.#cachedLines = undefined;
|
|
1706
1867
|
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Width-independent source prefix of the last render ending at a frozen
|
|
1871
|
+
* Markdown block boundary. Only meaningful while streaming (transient
|
|
1872
|
+
* render cache on); grows monotonically under append-only `setText`.
|
|
1873
|
+
*/
|
|
1874
|
+
getLastRenderStableText(): string {
|
|
1875
|
+
return this.#transientRenderCache ? (this.#streamPrefixText ?? "") : "";
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1707
1878
|
get transientRenderCache(): boolean {
|
|
1708
1879
|
return this.#transientRenderCache;
|
|
1709
1880
|
}
|
|
@@ -1717,6 +1888,11 @@ export class Markdown implements Component {
|
|
|
1717
1888
|
// so a memo computed on the other mode's buffer must not be reused —
|
|
1718
1889
|
// re-derive on the next frame instead.
|
|
1719
1890
|
this.#appendOnlySinceLastScan = false;
|
|
1891
|
+
// B+ reset site: a transient flip (finalize, or a fresh stream on
|
|
1892
|
+
// rewound text) means the next render re-lexes from the current
|
|
1893
|
+
// source — drop the fast-path recipe so stale rows cannot be served
|
|
1894
|
+
// across the transition.
|
|
1895
|
+
this.#fastTail = undefined;
|
|
1720
1896
|
this.invalidate();
|
|
1721
1897
|
}
|
|
1722
1898
|
|
|
@@ -1863,6 +2039,132 @@ export class Markdown implements Component {
|
|
|
1863
2039
|
this.#lastScanValid = false;
|
|
1864
2040
|
}
|
|
1865
2041
|
const signature = this.#renderSignature(width, paddingX);
|
|
2042
|
+
// B+ fast path: an append-only, same-line delta re-renders ONLY the
|
|
2043
|
+
// last content row (the paragraph's trailing wrapped row) with the
|
|
2044
|
+
// grown source, so the new text shows every frame while staying
|
|
2045
|
+
// byte-identical to a cold full render (the full re-lex produces the
|
|
2046
|
+
// same grown inline tokens and the same wrap). The previous frame's
|
|
2047
|
+
// rows live in #fastTail.lines — the L1 #cachedLines was invalidated
|
|
2048
|
+
// by setText, so the fast path cannot read it back. An inert delta
|
|
2049
|
+
// has no "\n", so the frozen prefix cannot advance and the rows above
|
|
2050
|
+
// the spliced span stay byte-identical.
|
|
2051
|
+
if (
|
|
2052
|
+
this.transientRenderCache &&
|
|
2053
|
+
this.#fastTail !== undefined &&
|
|
2054
|
+
contentWidth === this.#fastTail.width &&
|
|
2055
|
+
this.#text.length > this.#fastTail.source.length &&
|
|
2056
|
+
this.#text.startsWith(this.#fastTail.source)
|
|
2057
|
+
) {
|
|
2058
|
+
// Re-probe signature (pure) and require equality — a bgColor/theme
|
|
2059
|
+
// change (width-constant) must not splice rows of the stale recipe.
|
|
2060
|
+
const recipe = this.#fastTail;
|
|
2061
|
+
if (!this.#signatureEquals(signature, recipe.signature)) {
|
|
2062
|
+
this.#fastTail = undefined;
|
|
2063
|
+
} else {
|
|
2064
|
+
const delta = this.#text.slice(recipe.source.length);
|
|
2065
|
+
const deltaTabs = replaceTabs(delta);
|
|
2066
|
+
// Seam window contains the delta, so one URL/email scan catches both.
|
|
2067
|
+
const seamSafe = fastTailSeamSafe(recipe.rowRaw);
|
|
2068
|
+
const seamWindow =
|
|
2069
|
+
recipe.rowRaw.slice(Math.max(recipe.rowRaw.lastIndexOf(" "), recipe.rowRaw.lastIndexOf("\t")) + 1) +
|
|
2070
|
+
deltaTabs;
|
|
2071
|
+
// A paragraph's last line can complete into a different block
|
|
2072
|
+
// kind under an inert delta — disarm (single gate helper, kept
|
|
2073
|
+
// in sync with the exported test surface).
|
|
2074
|
+
const grownLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
|
|
2075
|
+
const lineStartHazard = fastLineStartHazard(grownLine);
|
|
2076
|
+
// Only same-line deltas splice; marker deltas re-lex through the
|
|
2077
|
+
// REAL inline pipeline so self-contained pairs render styled.
|
|
2078
|
+
const hardDelta = /[\n\r\x1b]/.test(delta);
|
|
2079
|
+
const markerDelta = FAST_DELTA_RE.test(delta);
|
|
2080
|
+
// A delta starting/ending `_` after a word char pairs in isolation
|
|
2081
|
+
// but stays intraword-literal in the full text. Symmetrically, a
|
|
2082
|
+
// row ending with a closing delimiter (`_`, `*`, `~`) followed by
|
|
2083
|
+
// a word-char delta makes the delimiter intraword / non-flanking in
|
|
2084
|
+
// the joined text — the cold render drops the emphasis, but the
|
|
2085
|
+
// splice keeps it. A row ending `$` (closed inline math) followed
|
|
2086
|
+
// by a digit is invalidated by the anti-currency rule ($x$123 is
|
|
2087
|
+
// literal, not math) — disarm.
|
|
2088
|
+
const grownLastLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
|
|
2089
|
+
const trailingDelimiterSeamHazard =
|
|
2090
|
+
(markerDelta &&
|
|
2091
|
+
(deltaTabs.startsWith("_") || deltaTabs.endsWith("_")) &&
|
|
2092
|
+
FAST_UNDERSCORE_WORD_AT_END_RE.test(recipe.rowRaw)) ||
|
|
2093
|
+
(!markerDelta && /[*~_]$/.test(recipe.rowRaw) && FAST_CMARK_WORD_AT_START_RE.test(deltaTabs)) ||
|
|
2094
|
+
(!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
|
|
2095
|
+
// A delta opening a pairing char when the captured row ENDS with the
|
|
2096
|
+
// same char can re-pair across the seam: cold lex of the joined run
|
|
2097
|
+
// makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
|
|
2098
|
+
// An image marker (`x!` + `[a](u)`) re-pairs the same way.
|
|
2099
|
+
const pairSeamHazard =
|
|
2100
|
+
markerDelta &&
|
|
2101
|
+
((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
|
|
2102
|
+
// "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
|
|
2103
|
+
// keep "x!" + a styled link byte-run.
|
|
2104
|
+
(deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
|
|
2105
|
+
const deltaTokens = markerDelta && !hardDelta ? lexInlineTokens(deltaTabs) : null;
|
|
2106
|
+
if (
|
|
2107
|
+
seamSafe &&
|
|
2108
|
+
!lineStartHazard &&
|
|
2109
|
+
!hardDelta &&
|
|
2110
|
+
!trailingDelimiterSeamHazard &&
|
|
2111
|
+
// A grown GFM delimiter last line flips a preceding pipe-header
|
|
2112
|
+
// into a table on a marker-free delta (`| --` + `--- | -`).
|
|
2113
|
+
!FAST_TABLE_DELIM_ROW_RE.test(grownLastLine) &&
|
|
2114
|
+
(!markerDelta || (!this.#lastTailCapture?.open && !pairSeamHazard && !inlineHasOpen(deltaTokens!))) &&
|
|
2115
|
+
!FAST_URL_ANYWHERE_RE.test(seamWindow)
|
|
2116
|
+
) {
|
|
2117
|
+
// Same text paths a full re-lex applies: real pipeline for marker
|
|
2118
|
+
// deltas, plain swatch/entity render for inert deltas.
|
|
2119
|
+
const { applyText } = this.#getDefaultInlineStyleContext();
|
|
2120
|
+
const grown =
|
|
2121
|
+
recipe.rowText +
|
|
2122
|
+
(markerDelta
|
|
2123
|
+
? this.#renderInlineTokens(deltaTokens!)
|
|
2124
|
+
: renderTextWithSwatches(
|
|
2125
|
+
normalizeHtmlEntitiesForTerminal(deltaTabs),
|
|
2126
|
+
applyText,
|
|
2127
|
+
this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
|
|
2128
|
+
));
|
|
2129
|
+
const wrapped = wrapTextWithAnsi(grown, contentWidth);
|
|
2130
|
+
const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
2131
|
+
const leftMargin = padding(fastPaddingX);
|
|
2132
|
+
const rightMargin = padding(fastPaddingX);
|
|
2133
|
+
const bgFn = this.#defaultTextStyle?.bgColor;
|
|
2134
|
+
const fastRows: string[] = [];
|
|
2135
|
+
for (const row of wrapped) {
|
|
2136
|
+
const withMargins = leftMargin + row + rightMargin;
|
|
2137
|
+
fastRows.push(
|
|
2138
|
+
bgFn
|
|
2139
|
+
? applyBackgroundToLine(withMargins, width, bgFn)
|
|
2140
|
+
: withMargins + padding(Math.max(0, width - visibleWidth(withMargins))),
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
// Splice onto the previous frame's rows (new array — parent may
|
|
2144
|
+
// hold the old one).
|
|
2145
|
+
const prev = recipe.lines;
|
|
2146
|
+
const fastResult = [...prev.slice(0, recipe.rowStart), ...fastRows, ...prev.slice(recipe.rowEnd)];
|
|
2147
|
+
this.#cachedText = this.#text;
|
|
2148
|
+
this.#cachedWidth = width;
|
|
2149
|
+
this.#cachedLines = fastResult;
|
|
2150
|
+
this.#fastTail = {
|
|
2151
|
+
lines: fastResult,
|
|
2152
|
+
source: this.#text,
|
|
2153
|
+
width: recipe.width,
|
|
2154
|
+
rowText: wrapped[wrapped.length - 1] ?? "",
|
|
2155
|
+
rowRaw: recipe.rowRaw + deltaTabs,
|
|
2156
|
+
rowStart: recipe.rowStart + wrapped.length - 1,
|
|
2157
|
+
rowEnd: recipe.rowStart + wrapped.length,
|
|
2158
|
+
signature: recipe.signature,
|
|
2159
|
+
};
|
|
2160
|
+
fastTailSplices++;
|
|
2161
|
+
return fastResult;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
// Hazard → disarm until the next real render re-captures.
|
|
2165
|
+
this.#fastTail = undefined;
|
|
2166
|
+
}
|
|
2167
|
+
// Replace tabs with 3 spaces for consistent rendering
|
|
1866
2168
|
|
|
1867
2169
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
1868
2170
|
// session-tree navigations. Key encodes every dimension that affects the
|
|
@@ -1913,6 +2215,37 @@ export class Markdown implements Component {
|
|
|
1913
2215
|
this.#cachedWidth = width;
|
|
1914
2216
|
this.#cachedLines = result;
|
|
1915
2217
|
|
|
2218
|
+
const fastEligible =
|
|
2219
|
+
this.transientRenderCache &&
|
|
2220
|
+
contentLines.length > 0 &&
|
|
2221
|
+
// B+ invariant: only the FINAL #renderContentLines call captures;
|
|
2222
|
+
// the all-cache-hit prefix path clears #lastTailCapture at its top.
|
|
2223
|
+
this.#lastTailCapture !== undefined &&
|
|
2224
|
+
this.#lastTailCapture.kind === "paragraph" &&
|
|
2225
|
+
// Run-level default styling (color/bold/italic/strikethrough/
|
|
2226
|
+
// underline) disarms: the splice yields two ANSI runs where a cold
|
|
2227
|
+
// render yields one; bgColor is line-level and stays eligible.
|
|
2228
|
+
!this.#defaultTextStyle?.color &&
|
|
2229
|
+
!this.#defaultTextStyle?.bold &&
|
|
2230
|
+
!this.#defaultTextStyle?.italic &&
|
|
2231
|
+
!this.#defaultTextStyle?.strikethrough &&
|
|
2232
|
+
!this.#defaultTextStyle?.underline;
|
|
2233
|
+
if (fastEligible && this.#lastTailCapture !== undefined) {
|
|
2234
|
+
const capture = this.#lastTailCapture;
|
|
2235
|
+
this.#fastTail = {
|
|
2236
|
+
lines: result,
|
|
2237
|
+
source: this.#text,
|
|
2238
|
+
width: contentWidth,
|
|
2239
|
+
rowText: capture.rowInput,
|
|
2240
|
+
rowRaw: capture.rowRaw,
|
|
2241
|
+
rowStart: signature.paddingY + contentLines.length - 1,
|
|
2242
|
+
rowEnd: signature.paddingY + contentLines.length,
|
|
2243
|
+
signature,
|
|
2244
|
+
};
|
|
2245
|
+
} else {
|
|
2246
|
+
this.#fastTail = undefined;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1916
2249
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
1917
2250
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1918
2251
|
if (cacheKey !== undefined) {
|
|
@@ -1938,6 +2271,10 @@ export class Markdown implements Component {
|
|
|
1938
2271
|
headingProbe,
|
|
1939
2272
|
};
|
|
1940
2273
|
}
|
|
2274
|
+
// All-primitive signature — compare via the canonical render-cache encoding.
|
|
2275
|
+
#signatureEquals(a: RenderSignature, b: RenderSignature): boolean {
|
|
2276
|
+
return this.#renderCacheKey("", a) === this.#renderCacheKey("", b);
|
|
2277
|
+
}
|
|
1941
2278
|
|
|
1942
2279
|
#renderCacheKey(normalizedText: string, signature: RenderSignature): string {
|
|
1943
2280
|
return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}`;
|
|
@@ -2114,6 +2451,9 @@ export class Markdown implements Component {
|
|
|
2114
2451
|
signature: RenderSignature,
|
|
2115
2452
|
tailRecorder?: TailRenderRecorder,
|
|
2116
2453
|
): string[] {
|
|
2454
|
+
// A non-capturing final call must not serve a stale recipe, so the
|
|
2455
|
+
// B+ plumbing is cleared up front; the per-token capture re-fills it.
|
|
2456
|
+
if (end === tokens.length) this.#lastTailCapture = undefined;
|
|
2117
2457
|
const wrappedLines: RenderedLine[] = [];
|
|
2118
2458
|
// Wrapped-row span per absolute token index. Call-local: stale values
|
|
2119
2459
|
// are never read across renders.
|
|
@@ -2141,6 +2481,36 @@ export class Markdown implements Component {
|
|
|
2141
2481
|
}
|
|
2142
2482
|
}
|
|
2143
2483
|
tokenWrappedRowCounts[i] = wrappedLines.length - tokenWrappedRowStart;
|
|
2484
|
+
// B+ capture hook: the LAST token of the FINAL call is the frame's
|
|
2485
|
+
// true trailing content row — record its rendered last row and raw
|
|
2486
|
+
// tail so render() can build the fast-path recipe. The frozen
|
|
2487
|
+
// prefix call (end < tokens.length) must never capture.
|
|
2488
|
+
if (end === tokens.length && i === end - 1 && token.type === "paragraph") {
|
|
2489
|
+
const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
|
|
2490
|
+
const lastLine = renderedTokenLines[renderedTokenLines.length - 1];
|
|
2491
|
+
// Display math, a newline-terminated paragraph (a fresh line
|
|
2492
|
+
// grows next frame — the captured row is not the mutable tail),
|
|
2493
|
+
// or a tree-guide/OSC-8/OSC-66 trailing row are not
|
|
2494
|
+
// self-contained.
|
|
2495
|
+
if (
|
|
2496
|
+
soleDisplayMath(token.tokens) ||
|
|
2497
|
+
raw.endsWith("\n") ||
|
|
2498
|
+
!lastLine ||
|
|
2499
|
+
TREE_GUIDE_ANCHOR_RE.test(lastLine.text) ||
|
|
2500
|
+
lastLine.text.includes("\x1b]") ||
|
|
2501
|
+
TERMINAL.isImageLine(lastLine.text) ||
|
|
2502
|
+
isOsc66Line(lastLine.text)
|
|
2503
|
+
) {
|
|
2504
|
+
continue;
|
|
2505
|
+
}
|
|
2506
|
+
const wrappedLast = wrappedLines[wrappedLines.length - 1];
|
|
2507
|
+
this.#lastTailCapture = {
|
|
2508
|
+
kind: "paragraph",
|
|
2509
|
+
rowInput: wrappedLast?.text ?? lastLine.text,
|
|
2510
|
+
rowRaw: raw.slice(raw.lastIndexOf("\n") + 1),
|
|
2511
|
+
open: inlineHasOpen(token.tokens ?? []),
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2144
2514
|
}
|
|
2145
2515
|
|
|
2146
2516
|
const leftMargin = padding(signature.paddingX);
|
package/src/tui.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Minimal TUI implementation with explicit history batches.
|
|
3
3
|
*
|
|
4
4
|
* Two output channels: a product-owned {@link TerminalFrameProvider} returns,
|
|
5
|
-
* per frame, an optional immutable {@link HistoryBatch} (finalized
|
|
6
|
-
*
|
|
5
|
+
* per frame, an optional immutable {@link HistoryBatch} (finalized or naturally
|
|
6
|
+
* emitted stable rows, or one complete replay, gated by a monotonic id and
|
|
7
7
|
* acknowledgement) plus the complete mutable viewport. The writer anchors the
|
|
8
8
|
* viewport directly below whatever history remains visible, diffs
|
|
9
9
|
* viewport-only frames, and never infers finality from a row's position.
|
|
@@ -109,13 +109,20 @@ export interface ViewportSize {
|
|
|
109
109
|
readonly rows: number;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/** Immutable
|
|
112
|
+
/** Immutable append or complete replay offered until the terminal accepts this identifier. */
|
|
113
113
|
export interface HistoryBatch {
|
|
114
114
|
readonly id: number;
|
|
115
115
|
readonly rows: readonly string[];
|
|
116
|
+
/**
|
|
117
|
+
* `append` (the default) adds finalized or naturally emitted rows. `replay`
|
|
118
|
+
* is the complete logical ledger; the writer bottom-splits it against the
|
|
119
|
+
* leading blank viewport and serializes the remainder plus final viewport in
|
|
120
|
+
* one synchronous terminal write.
|
|
121
|
+
*/
|
|
122
|
+
readonly kind?: "append" | "replay";
|
|
116
123
|
}
|
|
117
124
|
|
|
118
|
-
/** One history append
|
|
125
|
+
/** One history append or complete replay plus the mutable viewport for a terminal frame. */
|
|
119
126
|
export interface TerminalFramePlan {
|
|
120
127
|
readonly history?: HistoryBatch;
|
|
121
128
|
readonly viewport: readonly string[];
|
|
@@ -128,7 +135,9 @@ export interface TerminalFrameProvider {
|
|
|
128
135
|
/** Full semantic viewport used only on the transient resize buffer. */
|
|
129
136
|
renderResizeFrame?(viewport: ViewportSize): readonly string[];
|
|
130
137
|
/** Re-offer finalized history after a display reset or resize replay. */
|
|
131
|
-
|
|
138
|
+
beginHistoryReplay?(): void;
|
|
139
|
+
/** Force every currently eligible finalized prefix to retire before stop. */
|
|
140
|
+
beginHistoryFlush?(): void;
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
export interface TUIStartOptions {
|
|
@@ -640,9 +649,50 @@ export class TUI extends Container {
|
|
|
640
649
|
#parkedViewportOffset = 0;
|
|
641
650
|
// In-flight post-resize anchor probe: the stale viewport snapshot and park
|
|
642
651
|
// offset captured when CSI 6n was written, plus the no-reply fallback timer.
|
|
643
|
-
#resizeProbe:
|
|
644
|
-
|
|
645
|
-
|
|
652
|
+
#resizeProbe:
|
|
653
|
+
| {
|
|
654
|
+
window: readonly string[];
|
|
655
|
+
offset: number;
|
|
656
|
+
timer: RenderTimer;
|
|
657
|
+
epoch: number;
|
|
658
|
+
retried: boolean;
|
|
659
|
+
}
|
|
660
|
+
| undefined;
|
|
661
|
+
// Pre-erase viewport snapshot for the settled resize-anchor probe: the erase
|
|
662
|
+
// in #beginResizeAltPaint empties #providerWindow, so the probe must bound
|
|
663
|
+
// the anchor with the window that was actually on screen when the resize
|
|
664
|
+
// began (see #resolveResizeAnchor's `height - staleRows` clamp).
|
|
665
|
+
#resizeProbeWindow: readonly string[] = [];
|
|
666
|
+
#resizeProbeOffset = 0;
|
|
667
|
+
// Direction tracking for the current coalesced resize burst (reset when a
|
|
668
|
+
// plan frame commits, alongside #previousHeight). A burst containing any
|
|
669
|
+
// height grow invalidates the multiplexer clip model in
|
|
670
|
+
// #resolveResizeAnchor: the grow pulls scrollback into the pane and moves
|
|
671
|
+
// the parked logical row, so the net shrink no longer telescopes from
|
|
672
|
+
// pre-burst state.
|
|
673
|
+
#resizeBurstGrew = false;
|
|
674
|
+
#resizeBurstLastHeight: number | undefined;
|
|
675
|
+
// Sum of every grow step in the burst: bounds how much scrollback a
|
|
676
|
+
// multiplexer can have pulled down across the whole burst, including a
|
|
677
|
+
// shrink-then-regrow that never exceeds the pre-burst height (see the
|
|
678
|
+
// CPR-timeout fallback in #resolveResizeAnchor).
|
|
679
|
+
#resizeBurstPull = 0;
|
|
680
|
+
// Geometry epoch: bumped on every resize transaction entry, so each CSI 6n
|
|
681
|
+
// request records the geometry it was parked under.
|
|
682
|
+
#geometryEpoch = 0;
|
|
683
|
+
// CPR attribution: each request parks a distinct column (CHA) before its
|
|
684
|
+
// CSI 6n; the terminal processes requests serially, so every reply carries
|
|
685
|
+
// its own request's column. That makes attribution exact even when replies
|
|
686
|
+
// are dropped or arbitrarily delayed — anonymous FIFO counting cannot
|
|
687
|
+
// survive drops (forgetting retired requests eagerly misattributes late
|
|
688
|
+
// replies, remembering them forever poisons later probes with phantoms,
|
|
689
|
+
// and age expiry is unsound because replies carry no lifetime guarantee).
|
|
690
|
+
// A rewrap can only invalidate a reply's row via a width-change SIGWINCH,
|
|
691
|
+
// which bumps the geometry epoch and discards the reply anyway, so the
|
|
692
|
+
// scheme is sound on direct terminals too. Tags are never expired; a late
|
|
693
|
+
// reply to a dead tag is stripped and discarded by column.
|
|
694
|
+
#cprColumnTags = new Map<number, number>();
|
|
695
|
+
#cprProbeSeq = 0;
|
|
646
696
|
// Prepared rows painted by the previous provider frame, for row diffing.
|
|
647
697
|
#providerWindow: string[] = [];
|
|
648
698
|
#previousFrameLength = 0;
|
|
@@ -1004,7 +1054,7 @@ export class TUI extends Container {
|
|
|
1004
1054
|
if (this.#resizeProbe) {
|
|
1005
1055
|
// The anchor being probed is already stale; restart the transaction.
|
|
1006
1056
|
this.#cancelResizeProbe();
|
|
1007
|
-
this.#beginResizeAltPaint();
|
|
1057
|
+
this.#beginResizeAltPaint(true);
|
|
1008
1058
|
return;
|
|
1009
1059
|
}
|
|
1010
1060
|
if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
|
|
@@ -1032,12 +1082,23 @@ export class TUI extends Container {
|
|
|
1032
1082
|
}
|
|
1033
1083
|
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
1034
1084
|
}
|
|
1035
|
-
/**
|
|
1036
|
-
|
|
1085
|
+
/**
|
|
1086
|
+
* Borrow the alternate buffer for stable, history-free resize repainting.
|
|
1087
|
+
* `restartingProbe` marks a transaction restarted by a SIGWINCH that
|
|
1088
|
+
* arrived while the settled anchor probe was in flight: the live window
|
|
1089
|
+
* was already stashed and emptied, so the snapshot below must be skipped
|
|
1090
|
+
* to keep the good stash.
|
|
1091
|
+
*/
|
|
1092
|
+
#beginResizeAltPaint(restartingProbe = false): void {
|
|
1037
1093
|
if (this.#altActive) {
|
|
1038
1094
|
this.requestRender(true);
|
|
1039
1095
|
return;
|
|
1040
1096
|
}
|
|
1097
|
+
const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
|
|
1098
|
+
if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
|
|
1099
|
+
this.#resizeBurstLastHeight = this.terminal.rows;
|
|
1100
|
+
this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
|
|
1101
|
+
this.#geometryEpoch++;
|
|
1041
1102
|
if (!this.#resizeAltActive) {
|
|
1042
1103
|
this.#resizeAltActive = true;
|
|
1043
1104
|
setAltScreenActive(true);
|
|
@@ -1056,10 +1117,19 @@ export class TUI extends Container {
|
|
|
1056
1117
|
// cursor-relative movement lands on the viewport's top row. On height
|
|
1057
1118
|
// shrink kitty clamps the cursor instead of moving it with pushed rows,
|
|
1058
1119
|
// so cursor-relative addressing would start rows late; fall back to the
|
|
1059
|
-
// same bottom-preserving bound as resize-anchor recovery. The
|
|
1060
|
-
//
|
|
1120
|
+
// same bottom-preserving bound as resize-anchor recovery. The pre-erase
|
|
1121
|
+
// window is stashed for the settled CPR probe: its reflowed row count
|
|
1122
|
+
// bounds the anchor to `height - staleRows`, so a mis-parked cursor (a
|
|
1123
|
+
// single-step tmux zoom re-lays the pane before SIGWINCH delivery,
|
|
1124
|
+
// moving the park target under us) cannot anchor the settled repaint
|
|
1125
|
+
// over pulled-back history rows or scroll-push the frame into
|
|
1126
|
+
// scrollback again.
|
|
1061
1127
|
let erase = "";
|
|
1062
|
-
if (
|
|
1128
|
+
if (!restartingProbe) {
|
|
1129
|
+
this.#resizeProbeWindow = this.#providerWindow;
|
|
1130
|
+
this.#resizeProbeOffset = this.#parkedViewportOffset;
|
|
1131
|
+
}
|
|
1132
|
+
if (this.#hasEverRendered && this.#providerWindow.length > 0 && !isInsideTerminalMultiplexer()) {
|
|
1063
1133
|
if (this.terminal.rows < this.#previousHeight) {
|
|
1064
1134
|
const staleRows = this.#reflowedRowCount(
|
|
1065
1135
|
this.#providerWindow,
|
|
@@ -1078,6 +1148,22 @@ export class TUI extends Container {
|
|
|
1078
1148
|
);
|
|
1079
1149
|
erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}\r\x1b[J`;
|
|
1080
1150
|
}
|
|
1151
|
+
// Both erase paths leave the cursor on the viewport's top row, so the
|
|
1152
|
+
// parked offset no longer applies; carrying a stale nonzero offset
|
|
1153
|
+
// into the probe would anchor the settled repaint above the real
|
|
1154
|
+
// viewport top and overwrite visible committed rows.
|
|
1155
|
+
this.#resizeProbeOffset = 0;
|
|
1156
|
+
this.#providerWindow = [];
|
|
1157
|
+
this.#parkedViewportOffset = 0;
|
|
1158
|
+
}
|
|
1159
|
+
if (this.#hasEverRendered && this.#providerWindow.length > 0 && isInsideTerminalMultiplexer()) {
|
|
1160
|
+
// Multiplexers apply the pane re-layout on their own schedule relative
|
|
1161
|
+
// to SIGWINCH delivery, so an immediate erase races it: with the pane
|
|
1162
|
+
// already re-laid the stale coordinates blank pulled-back committed
|
|
1163
|
+
// rows (destroying popped scrollback), and with the pane not yet
|
|
1164
|
+
// re-laid the erase lands on rows about to move. Skip it — the
|
|
1165
|
+
// settled repaint overwrites the live region at the clip-model anchor
|
|
1166
|
+
// and erases below it, race-free after the quiet window.
|
|
1081
1167
|
this.#providerWindow = [];
|
|
1082
1168
|
this.#parkedViewportOffset = 0;
|
|
1083
1169
|
}
|
|
@@ -1103,14 +1189,60 @@ export class TUI extends Container {
|
|
|
1103
1189
|
* trip against the parked cursor reports where the viewport's logical line
|
|
1104
1190
|
* landed. The settled repaint waits for the reply (or a short timeout).
|
|
1105
1191
|
*/
|
|
1106
|
-
#beginResizeAnchorProbe(): void {
|
|
1192
|
+
#beginResizeAnchorProbe(retry = false): void {
|
|
1107
1193
|
this.#cancelResizeProbe();
|
|
1108
1194
|
const timer = this.#renderScheduler.scheduleRender(() => {
|
|
1195
|
+
const probe = this.#resizeProbe;
|
|
1196
|
+
if (probe !== undefined && !probe.retried && (isInsideTerminalMultiplexer() || this.#resizeBurstGrew)) {
|
|
1197
|
+
// A CPR-less resolve is heuristic: a grow's pull span is unknown
|
|
1198
|
+
// on any terminal, and SIGWINCH coalescing can hide intermediate
|
|
1199
|
+
// grows entirely, so even an observed-monotonic multiplexer
|
|
1200
|
+
// shrink cannot be modeled with certainty. A dropped DSR reply
|
|
1201
|
+
// is a transient race — multiplexers in particular answer DSR
|
|
1202
|
+
// themselves — so ask once more before falling back.
|
|
1203
|
+
this.#beginResizeAnchorProbe(true);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1109
1206
|
this.#resolveResizeAnchor(undefined);
|
|
1110
1207
|
}, TUI.#RESIZE_PROBE_TIMEOUT_MS);
|
|
1111
|
-
this.#resizeProbe = {
|
|
1112
|
-
|
|
1113
|
-
|
|
1208
|
+
this.#resizeProbe = {
|
|
1209
|
+
window: this.#resizeProbeWindow,
|
|
1210
|
+
offset: this.#resizeProbeOffset,
|
|
1211
|
+
timer,
|
|
1212
|
+
epoch: this.#geometryEpoch,
|
|
1213
|
+
retried: retry,
|
|
1214
|
+
};
|
|
1215
|
+
// Tags are never expired by age: a reply has no lifetime guarantee, and
|
|
1216
|
+
// freeing a column while its reply may still arrive would let that
|
|
1217
|
+
// reply match a newer tag on the reused column. Dead tags only
|
|
1218
|
+
// accumulate from genuinely dropped replies; a terminal that drops
|
|
1219
|
+
// enough of them to exhaust the span earns the timeout-only fallback.
|
|
1220
|
+
// Park a distinct column for this request so its reply is
|
|
1221
|
+
// self-identifying, then return the cursor to column 1 immediately:
|
|
1222
|
+
// the reply snapshots the column when the terminal processes the CSI
|
|
1223
|
+
// 6n, but a cursor RESTING on a nonzero column would reflow onto a
|
|
1224
|
+
// later visual row if a direct terminal's width later shrank below it,
|
|
1225
|
+
// corrupting the next probe's cursor-relative math. Columns 1-16 are
|
|
1226
|
+
// never used as tags: column 1 cannot be told apart from a spurious or
|
|
1227
|
+
// clamped reply, and modified F3 keys encode as CSI 1;<mod>R with
|
|
1228
|
+
// modifier codes 2-16, which is byte-identical to a CPR for row 1 on
|
|
1229
|
+
// those columns. A column may not be reused while its tag is live —
|
|
1230
|
+
// the old request's delayed reply would be attributed to the new
|
|
1231
|
+
// epoch — so scan for a free slot.
|
|
1232
|
+
const span = Math.min(30, this.terminal.columns - 16);
|
|
1233
|
+
if (span >= 4) {
|
|
1234
|
+
for (let index = 0; index < span; index++) {
|
|
1235
|
+
const candidate = 17 + ((this.#cprProbeSeq + index) % span);
|
|
1236
|
+
if (this.#cprColumnTags.has(candidate)) continue;
|
|
1237
|
+
this.#cprProbeSeq += index + 1;
|
|
1238
|
+
this.#cprColumnTags.set(candidate, this.#geometryEpoch);
|
|
1239
|
+
this.terminal.write(`\x1b[${candidate}G\x1b[6n\x1b[1G`);
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Degenerate span or full occupancy: an untagged reply could never be
|
|
1244
|
+
// attributed, so no DSR is sent at all; the timeout anchors
|
|
1245
|
+
// conservatively on its own.
|
|
1114
1246
|
}
|
|
1115
1247
|
|
|
1116
1248
|
#cancelResizeProbe(): void {
|
|
@@ -1121,19 +1253,25 @@ export class TUI extends Container {
|
|
|
1121
1253
|
|
|
1122
1254
|
/**
|
|
1123
1255
|
* Anchor the settled post-resize repaint. `reportedRow` is the 0-based CPR
|
|
1124
|
-
* row of the parked cursor (undefined = probe timed out).
|
|
1125
|
-
*
|
|
1256
|
+
* row of the parked cursor (undefined = probe timed out). Direct terminals
|
|
1257
|
+
* use `min(reported - parkOffset, height - staleRows)`: they track the
|
|
1126
1258
|
* cursor exactly through width rewrap, and the second bound reconstructs
|
|
1127
|
-
* height-shrink scrollback pushes that leave the cursor behind (kitty
|
|
1128
|
-
* the cursor instead of scrolling it) — bottom-preserving resize
|
|
1129
|
-
* the stale viewport ends on the last screen row whenever a
|
|
1130
|
-
*
|
|
1259
|
+
* height-shrink scrollback pushes that leave the cursor behind (kitty
|
|
1260
|
+
* clamps the cursor instead of scrolling it) — bottom-preserving resize
|
|
1261
|
+
* guarantees the stale viewport ends on the last screen row whenever a
|
|
1262
|
+
* push happened; validated against kitty's real core in
|
|
1263
|
+
* resize-anchor-recovery.test.ts. Multiplexers clip on height changes
|
|
1264
|
+
* (though they reflow on width changes), so that bound never applies:
|
|
1265
|
+
* monotonic shrinks use the deterministic clip model below, everything
|
|
1266
|
+
* else trusts the CPR directly.
|
|
1131
1267
|
*/
|
|
1132
1268
|
#resolveResizeAnchor(reportedRow: number | undefined): void {
|
|
1133
1269
|
const probe = this.#resizeProbe;
|
|
1134
1270
|
if (!probe) return;
|
|
1135
1271
|
probe.timer.cancel();
|
|
1136
1272
|
this.#resizeProbe = undefined;
|
|
1273
|
+
// Column tags stay live across resolves: their replies are
|
|
1274
|
+
// self-identifying and discarded by tag whenever they arrive.
|
|
1137
1275
|
const width = this.terminal.columns;
|
|
1138
1276
|
const height = this.terminal.rows;
|
|
1139
1277
|
const staleRows = this.#reflowedRowCount(probe.window, 0, probe.window.length, width);
|
|
@@ -1141,7 +1279,58 @@ export class TUI extends Container {
|
|
|
1141
1279
|
reportedRow === undefined
|
|
1142
1280
|
? this.#providerViewportTop
|
|
1143
1281
|
: reportedRow - this.#reflowedRowCount(probe.window, 0, probe.offset, width);
|
|
1144
|
-
|
|
1282
|
+
let top: number;
|
|
1283
|
+
if (isInsideTerminalMultiplexer()) {
|
|
1284
|
+
if (reportedRow !== undefined) {
|
|
1285
|
+
// The parked cursor's reply is exact under multiplexer clipping:
|
|
1286
|
+
// discards leave the cursor in place, pushes only occur after
|
|
1287
|
+
// everything below it is discarded (the bottom row IS the
|
|
1288
|
+
// attached position), and grow pull-down rides it down. It
|
|
1289
|
+
// therefore also reflects intermediate geometries that SIGWINCH
|
|
1290
|
+
// coalescing hid from the burst tracker, and always outranks the
|
|
1291
|
+
// clip model. The `height - staleRows` bound must NOT apply here:
|
|
1292
|
+
// it encodes bottom-preserving rewrap, but a multiplexer shrink
|
|
1293
|
+
// may have discarded stale rows below the cursor instead of
|
|
1294
|
+
// pushing the top ones. Frame-size clamping happens when the
|
|
1295
|
+
// settled plan frame is emitted.
|
|
1296
|
+
top = Math.max(0, reportedTop);
|
|
1297
|
+
} else if (height < this.#previousHeight && !this.#resizeBurstGrew) {
|
|
1298
|
+
// Last resort after the retry: model the clip deterministically
|
|
1299
|
+
// from the saved parked cursor. Rows strictly below the cursor
|
|
1300
|
+
// are discarded first (even non-blank ones — measured against
|
|
1301
|
+
// real tmux), and only the remainder of the shrink pushes top
|
|
1302
|
+
// rows into scrollback; across an observed burst the totals
|
|
1303
|
+
// telescope from pre-burst state. SIGWINCH coalescing can hide a
|
|
1304
|
+
// grow from this model, which is why a reply always wins above.
|
|
1305
|
+
const parkedRow = this.#providerViewportTop + this.#reflowedRowCount(probe.window, 0, probe.offset, width);
|
|
1306
|
+
const shrink = this.#previousHeight - height;
|
|
1307
|
+
const discardedBelow = Math.min(shrink, Math.max(0, this.#previousHeight - 1 - parkedRow));
|
|
1308
|
+
const pushed = Math.max(0, shrink - discardedBelow);
|
|
1309
|
+
top = Math.max(0, this.#providerViewportTop - pushed);
|
|
1310
|
+
} else {
|
|
1311
|
+
// CPR-less grow or reversed burst: the pre-resize top is
|
|
1312
|
+
// stale-low, every grow step already pulled scrollback down.
|
|
1313
|
+
// Anchor at the conservative upper bound — pull never exceeds
|
|
1314
|
+
// the burst's accumulated growth, and pushes/discards only lower
|
|
1315
|
+
// the top. Exact when scrollback covers the pull; when it does
|
|
1316
|
+
// not, the repaint lands below the real viewport and leaves
|
|
1317
|
+
// stale rows above rather than overwriting committed ones.
|
|
1318
|
+
top = Math.max(0, this.#providerViewportTop + this.#resizeBurstPull);
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
// Direct terminals rewrap bottom-preserving: with `staleRows` stale
|
|
1322
|
+
// rows on screen the viewport top cannot exceed `height - staleRows`
|
|
1323
|
+
// whenever a push happened, so the bound reconstructs height-shrink
|
|
1324
|
+
// pushes that leave the cursor behind (kitty clamps the cursor
|
|
1325
|
+
// instead of scrolling it). A CPR-less grow is stale-low like the
|
|
1326
|
+
// multiplexer case — grow pull-down moved the real viewport — so it
|
|
1327
|
+
// anchors at the accumulated pull bound, still under the clamp.
|
|
1328
|
+
const fallbackTop =
|
|
1329
|
+
reportedRow === undefined && this.#resizeBurstGrew
|
|
1330
|
+
? this.#providerViewportTop + this.#resizeBurstPull
|
|
1331
|
+
: reportedTop;
|
|
1332
|
+
top = Math.max(0, Math.min(fallbackTop, height - staleRows));
|
|
1333
|
+
}
|
|
1145
1334
|
if ($flag("PI_DEBUG_REDRAW")) {
|
|
1146
1335
|
const msg = `[${new Date().toISOString()}] resize anchor: size=${width}x${height} cpr=${reportedRow ?? "timeout"} park=${probe.offset} stale=${staleRows} old=${this.#providerViewportTop} top=${top}\n`;
|
|
1147
1336
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
@@ -1152,14 +1341,17 @@ export class TUI extends Container {
|
|
|
1152
1341
|
}
|
|
1153
1342
|
|
|
1154
1343
|
/**
|
|
1155
|
-
* Rows `[start, end)` of a previously painted window re-measured at
|
|
1156
|
-
*
|
|
1157
|
-
* multiplexers
|
|
1344
|
+
* Rows `[start, end)` of a previously painted window re-measured at
|
|
1345
|
+
* `width`. Every terminal rewraps content on a width change — including
|
|
1346
|
+
* multiplexers: tmux clips in place on height changes only, and reflows
|
|
1347
|
+
* the pane (scrollback included) when the width moves, so a row painted
|
|
1348
|
+
* wider than the current width spans ceil(cells/width) physical rows
|
|
1349
|
+
* everywhere. For height-only resizes the painted rows already fit the
|
|
1350
|
+
* width and the count is unchanged.
|
|
1158
1351
|
*/
|
|
1159
1352
|
#reflowedRowCount(window: readonly string[], start: number, end: number, width: number): number {
|
|
1160
1353
|
const stop = Math.min(end, window.length);
|
|
1161
1354
|
const from = Math.max(0, start);
|
|
1162
|
-
if (isInsideTerminalMultiplexer()) return Math.max(0, stop - from);
|
|
1163
1355
|
let rows = 0;
|
|
1164
1356
|
for (let index = from; index < stop; index++) {
|
|
1165
1357
|
rows += Math.max(1, Math.ceil(visibleWidth(window[index]!) / Math.max(1, width)));
|
|
@@ -1340,6 +1532,28 @@ export class TUI extends Container {
|
|
|
1340
1532
|
this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
1341
1533
|
}
|
|
1342
1534
|
|
|
1535
|
+
#flushHistoryBeforeStop(): void {
|
|
1536
|
+
const provider = this.#frameProvider;
|
|
1537
|
+
if (provider?.beginHistoryFlush === undefined) return;
|
|
1538
|
+
const width = this.terminal.columns;
|
|
1539
|
+
const height = this.terminal.rows;
|
|
1540
|
+
if (width <= 0 || height <= 0) return;
|
|
1541
|
+
provider.beginHistoryFlush();
|
|
1542
|
+
while (true) {
|
|
1543
|
+
this.#imageBudget.beginPass();
|
|
1544
|
+
const plan = provider.renderFrame({ columns: width, rows: height });
|
|
1545
|
+
this.#imageBudget.endPass();
|
|
1546
|
+
if (plan.history === undefined) return;
|
|
1547
|
+
let viewport = Array.from(plan.viewport);
|
|
1548
|
+
if (viewport.length > height) viewport = viewport.slice(0, height);
|
|
1549
|
+
const acceptedBefore = this.#acceptedHistoryBatchId;
|
|
1550
|
+
this.#emitPlanFrame(width, height, viewport, plan.history, provider);
|
|
1551
|
+
if (plan.history.id > acceptedBefore && this.#acceptedHistoryBatchId === acceptedBefore) {
|
|
1552
|
+
throw new Error("History flush did not accept the offered batch");
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1343
1557
|
stop(): void {
|
|
1344
1558
|
this.#resizeSettleTimer?.cancel();
|
|
1345
1559
|
this.#resizeSettleTimer = undefined;
|
|
@@ -1359,6 +1573,12 @@ export class TUI extends Container {
|
|
|
1359
1573
|
this.#altPreviousLines = [];
|
|
1360
1574
|
this.#pendingAltExit = "";
|
|
1361
1575
|
}
|
|
1576
|
+
// A latched destructive reset (settled rebuild-mode resize, /clear) pairs
|
|
1577
|
+
// ED3 with a complete-ledger replay. Running that pair during stop would
|
|
1578
|
+
// erase native history and re-stream the whole transcript at quit; drop
|
|
1579
|
+
// the latch so the flush below writes only un-retired rows.
|
|
1580
|
+
this.#clearScrollbackOnNextRender = false;
|
|
1581
|
+
this.#flushHistoryBeforeStop();
|
|
1362
1582
|
// Deliberately leave transmitted images in the terminal's graphics store:
|
|
1363
1583
|
// placeholder cells committed to native scrollback render only while their
|
|
1364
1584
|
// image data lives, so a delete-by-id here blanks every transcript image
|
|
@@ -1492,7 +1712,7 @@ export class TUI extends Container {
|
|
|
1492
1712
|
}
|
|
1493
1713
|
#prepareForcedRender(clearScrollback: boolean): void {
|
|
1494
1714
|
if (clearScrollback && !this.#clearScrollbackOnNextRender) {
|
|
1495
|
-
this.#frameProvider?.
|
|
1715
|
+
this.#frameProvider?.beginHistoryReplay?.();
|
|
1496
1716
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) this.#imageBudget.forgetTransmitted();
|
|
1497
1717
|
}
|
|
1498
1718
|
this.#clearScrollbackOnNextRender ||= clearScrollback;
|
|
@@ -1568,12 +1788,39 @@ export class TUI extends Container {
|
|
|
1568
1788
|
// Consume CPR replies (CSI row;col R) while an anchor probe is unanswered;
|
|
1569
1789
|
// they are terminal reports, never keystrokes, and must not reach the
|
|
1570
1790
|
// focused component.
|
|
1571
|
-
|
|
1572
|
-
|
|
1791
|
+
let searchFrom = 0;
|
|
1792
|
+
while (this.#cprColumnTags.size > 0) {
|
|
1793
|
+
const match = data.slice(searchFrom).match(/\x1b\[(\d+);(\d+)R/);
|
|
1573
1794
|
if (!match || match.index === undefined) break;
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1795
|
+
const row = Number(match[1]);
|
|
1796
|
+
const column = Number(match[2]);
|
|
1797
|
+
if (!this.#cprColumnTags.has(column) && row === 1 && column >= 2) {
|
|
1798
|
+
// CSI 1;<mod>R with no live tag is modified F3: the modifier
|
|
1799
|
+
// parameter spans 2-256 once the lock-state bits (caps 64,
|
|
1800
|
+
// num 128) and hyper/meta are included, so no practical tag
|
|
1801
|
+
// range escapes it entirely. Anything row-1 we did not tag is
|
|
1802
|
+
// treated as a keystroke and left for the focused component;
|
|
1803
|
+
// a tagged column hit by a hyper/meta-modified F3 (params
|
|
1804
|
+
// 17-64, practically unused) is still gated by the epoch check
|
|
1805
|
+
// below.
|
|
1806
|
+
searchFrom += match.index + match[0].length;
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (this.#cprColumnTags.has(column)) {
|
|
1810
|
+
// Column-tagged reply: exact attribution. Resolve only when its
|
|
1811
|
+
// request was parked under the active probe's geometry; a reply
|
|
1812
|
+
// from an older epoch is stale and discarded.
|
|
1813
|
+
const tagEpoch = this.#cprColumnTags.get(column);
|
|
1814
|
+
this.#cprColumnTags.delete(column);
|
|
1815
|
+
const probe = this.#resizeProbe;
|
|
1816
|
+
if (probe !== undefined && tagEpoch === probe.epoch) {
|
|
1817
|
+
this.#resolveResizeAnchor(Number(match[1]) - 1);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
// Other unknown-column replies while expecting tagged ones are our
|
|
1821
|
+
// requests answered with a clamped or mangled column: strip and
|
|
1822
|
+
// discard; the probe timeout covers recovery.
|
|
1823
|
+
data = data.slice(0, searchFrom + match.index) + data.slice(searchFrom + match.index + match[0].length);
|
|
1577
1824
|
}
|
|
1578
1825
|
if (data.length === 0) return;
|
|
1579
1826
|
// Ctrl+C/Esc use app-level double-press windows. Give those gestures one
|
|
@@ -1983,7 +2230,7 @@ export class TUI extends Container {
|
|
|
1983
2230
|
return;
|
|
1984
2231
|
}
|
|
1985
2232
|
const provider = this.#frameProvider;
|
|
1986
|
-
if (!provider?.
|
|
2233
|
+
if (!provider?.beginHistoryReplay) return;
|
|
1987
2234
|
this.#resizeReplaySize = size;
|
|
1988
2235
|
if (this.#clearScrollbackOnNextRender) {
|
|
1989
2236
|
this.#forceViewportRepaintOnNextRender = true;
|
|
@@ -1993,15 +2240,14 @@ export class TUI extends Container {
|
|
|
1993
2240
|
this.#prepareForcedRender(true);
|
|
1994
2241
|
return;
|
|
1995
2242
|
}
|
|
1996
|
-
provider.
|
|
2243
|
+
provider.beginHistoryReplay();
|
|
1997
2244
|
this.#forceViewportRepaintOnNextRender = true;
|
|
1998
2245
|
}
|
|
1999
2246
|
|
|
2000
2247
|
/**
|
|
2001
|
-
* Physical write transaction: append an
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
* visible history — and the viewport anchor follows the retained history.
|
|
2248
|
+
* Physical write transaction: append an ordinary batch, or bottom-split one
|
|
2249
|
+
* complete replay into a history remainder and final viewport, then serialize
|
|
2250
|
+
* the whole result in one terminal write before acknowledgement.
|
|
2005
2251
|
*/
|
|
2006
2252
|
#emitPlanFrame(
|
|
2007
2253
|
width: number,
|
|
@@ -2015,11 +2261,28 @@ export class TUI extends Container {
|
|
|
2015
2261
|
while (viewport.length < height) viewport.push("");
|
|
2016
2262
|
viewport = this.#compositeOverlaysIntoWindow(viewport, width, height);
|
|
2017
2263
|
}
|
|
2018
|
-
const markers = this.#extractCursorMarkers(viewport);
|
|
2019
|
-
const prepared = this.#prepareLinesArray(viewport, width);
|
|
2020
2264
|
const history = offered !== undefined && offered.id > this.#acceptedHistoryBatchId ? offered : undefined;
|
|
2021
2265
|
if (offered !== undefined && offered.id <= this.#acceptedHistoryBatchId) provider?.acknowledgeHistory(offered.id);
|
|
2022
|
-
|
|
2266
|
+
|
|
2267
|
+
let historyRows = history?.rows ?? [];
|
|
2268
|
+
let replayViewportRows = 0;
|
|
2269
|
+
if (history?.kind === "replay") {
|
|
2270
|
+
// Providers may omit unused leading rows from a short viewport. Make
|
|
2271
|
+
// that logical space explicit before the bottom-first replay split.
|
|
2272
|
+
while (viewport.length < height) viewport.unshift("");
|
|
2273
|
+
let leadingBlankRows = 0;
|
|
2274
|
+
while (leadingBlankRows < viewport.length && !/\S/.test(viewport[leadingBlankRows]!)) {
|
|
2275
|
+
leadingBlankRows++;
|
|
2276
|
+
}
|
|
2277
|
+
const moved = Math.min(historyRows.length, leadingBlankRows);
|
|
2278
|
+
if (moved > 0) {
|
|
2279
|
+
viewport = [...historyRows.slice(historyRows.length - moved), ...viewport.slice(moved)];
|
|
2280
|
+
historyRows = historyRows.slice(0, historyRows.length - moved);
|
|
2281
|
+
replayViewportRows = moved;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const markers = this.#extractCursorMarkers(viewport);
|
|
2285
|
+
const prepared = this.#prepareLinesArray(viewport, width);
|
|
2023
2286
|
const preparedHistory = this.#prepareLinesArray(historyRows, width);
|
|
2024
2287
|
const rows = prepared.length;
|
|
2025
2288
|
// Destructive reset (session replace, /tree, explicit clear, or a settled
|
|
@@ -2051,7 +2314,14 @@ export class TUI extends Container {
|
|
|
2051
2314
|
} else {
|
|
2052
2315
|
this.#imageBudget.takePurgeIds();
|
|
2053
2316
|
}
|
|
2054
|
-
|
|
2317
|
+
// ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
|
|
2318
|
+
// into pane history (so cleared content stays reachable), so erasing
|
|
2319
|
+
// history first would let ED2 refill it with a copy of the old screen —
|
|
2320
|
+
// which the replay then repaints, duplicating one full frame per reset.
|
|
2321
|
+
// ED2-then-ED3 clears the screen, then wipes history including that
|
|
2322
|
+
// push. On xterm-family terminals the two erases are independent and
|
|
2323
|
+
// the order is irrelevant.
|
|
2324
|
+
if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
|
|
2055
2325
|
const diffable =
|
|
2056
2326
|
geometryStable &&
|
|
2057
2327
|
historyRows.length === 0 &&
|
|
@@ -2112,6 +2382,8 @@ export class TUI extends Container {
|
|
|
2112
2382
|
}
|
|
2113
2383
|
if (newTop + rows < height) buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
|
|
2114
2384
|
}
|
|
2385
|
+
const mutableTop = newTop + replayViewportRows;
|
|
2386
|
+
const mutablePrepared = replayViewportRows > 0 ? prepared.slice(replayViewportRows) : prepared;
|
|
2115
2387
|
const marker = markers[0];
|
|
2116
2388
|
const target =
|
|
2117
2389
|
marker !== undefined && rows > 0
|
|
@@ -2119,23 +2391,26 @@ export class TUI extends Container {
|
|
|
2119
2391
|
: null;
|
|
2120
2392
|
if (target) {
|
|
2121
2393
|
buffer += `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
|
|
2122
|
-
this.#parkedViewportOffset = Math.max(0, target.row -
|
|
2394
|
+
this.#parkedViewportOffset = Math.max(0, target.row - mutableTop);
|
|
2123
2395
|
} else {
|
|
2124
2396
|
// Park the hidden cursor on the viewport's top row: terminals keep the
|
|
2125
2397
|
// cursor attached to its logical line through resize reflow, so the
|
|
2126
2398
|
// post-resize anchor probe can recover where the viewport landed.
|
|
2127
|
-
buffer += `\x1b[?25l\x1b[${
|
|
2399
|
+
buffer += `\x1b[?25l\x1b[${mutableTop + 1};1H`;
|
|
2128
2400
|
this.#parkedViewportOffset = 0;
|
|
2129
2401
|
}
|
|
2130
2402
|
buffer += this.#paintEndSequence;
|
|
2131
2403
|
this.terminal.write(buffer);
|
|
2132
2404
|
if (target) this.#recordHardwareCursorState(target);
|
|
2133
2405
|
else this.#recordHardwareCursorHidden();
|
|
2134
|
-
this.#providerWindow =
|
|
2135
|
-
this.#providerViewportTop =
|
|
2406
|
+
this.#providerWindow = mutablePrepared;
|
|
2407
|
+
this.#providerViewportTop = mutableTop;
|
|
2136
2408
|
this.#previousWidth = width;
|
|
2137
2409
|
this.#previousHeight = height;
|
|
2138
|
-
this.#
|
|
2410
|
+
this.#resizeBurstGrew = false;
|
|
2411
|
+
this.#resizeBurstLastHeight = undefined;
|
|
2412
|
+
this.#resizeBurstPull = 0;
|
|
2413
|
+
this.#previousFrameLength = mutablePrepared.length;
|
|
2139
2414
|
this.#clearScrollbackOnNextRender = false;
|
|
2140
2415
|
this.#forceViewportRepaintOnNextRender = false;
|
|
2141
2416
|
this.#hasEverRendered = true;
|
|
@@ -2143,10 +2418,9 @@ export class TUI extends Container {
|
|
|
2143
2418
|
if (history !== undefined) {
|
|
2144
2419
|
this.#acceptedHistoryBatchId = history.id;
|
|
2145
2420
|
provider?.acknowledgeHistory(history.id);
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
|
|
2149
|
-
this.requestRender();
|
|
2421
|
+
// Normal retirement may hold another ordered batch. Replay is always
|
|
2422
|
+
// complete, so pumping it would create a second visible redraw/write.
|
|
2423
|
+
if (history.kind !== "replay") this.requestRender();
|
|
2150
2424
|
}
|
|
2151
2425
|
}
|
|
2152
2426
|
|