@oh-my-pi/pi-tui 16.2.11 → 16.2.12
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 +6 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +166 -42
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.12] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Optimized streaming markdown rendering to reuse already-rendered prefix lines and only render new content deltas, improving performance and reducing redraw flicker.
|
|
10
|
+
|
|
5
11
|
## [16.2.10] - 2026-06-30
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.12",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.2.12",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.2.12",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
|
@@ -769,6 +769,26 @@ function codespanSwatch(code: string, glyph: string): string {
|
|
|
769
769
|
return colorSwatch(match[1], glyph);
|
|
770
770
|
}
|
|
771
771
|
|
|
772
|
+
interface RenderSignature {
|
|
773
|
+
width: number;
|
|
774
|
+
paddingX: number;
|
|
775
|
+
paddingY: number;
|
|
776
|
+
codeBlockIndent: number;
|
|
777
|
+
themeId: number;
|
|
778
|
+
defaultTextStyleId: number;
|
|
779
|
+
imageProtocol: string;
|
|
780
|
+
hyperlinks: boolean;
|
|
781
|
+
textSizing: boolean;
|
|
782
|
+
bgColorProbe: string;
|
|
783
|
+
headingProbe: string;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
interface StreamPrefixLineCache extends RenderSignature {
|
|
787
|
+
text: string;
|
|
788
|
+
tokenCount: number;
|
|
789
|
+
lines: readonly string[];
|
|
790
|
+
}
|
|
791
|
+
|
|
772
792
|
export class Markdown implements Component {
|
|
773
793
|
#text: string;
|
|
774
794
|
#paddingX: number; // Left/right padding
|
|
@@ -796,6 +816,7 @@ export class Markdown implements Component {
|
|
|
796
816
|
// tokenization, so this cache is independent of the render caches above.
|
|
797
817
|
#streamPrefixText?: string;
|
|
798
818
|
#streamPrefixTokens?: Token[];
|
|
819
|
+
#streamPrefixLineCache?: StreamPrefixLineCache;
|
|
799
820
|
|
|
800
821
|
#ignoreTight = false;
|
|
801
822
|
|
|
@@ -829,6 +850,7 @@ export class Markdown implements Component {
|
|
|
829
850
|
// outlives the content it indexed.
|
|
830
851
|
this.#streamPrefixText = undefined;
|
|
831
852
|
this.#streamPrefixTokens = undefined;
|
|
853
|
+
this.#streamPrefixLineCache = undefined;
|
|
832
854
|
}
|
|
833
855
|
this.invalidate();
|
|
834
856
|
}
|
|
@@ -868,15 +890,16 @@ export class Markdown implements Component {
|
|
|
868
890
|
) {
|
|
869
891
|
const tailTokens = markdownParser.lexer(text.slice(prefix.length));
|
|
870
892
|
const tokens = [...prefixTokens, ...tailTokens];
|
|
871
|
-
this.#freezeStablePrefix(text, tokens);
|
|
893
|
+
this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
|
|
872
894
|
return tokens;
|
|
873
895
|
}
|
|
874
896
|
const tokens = markdownParser.lexer(text);
|
|
875
897
|
if (canStream) {
|
|
876
|
-
this.#freezeStablePrefix(text, tokens);
|
|
898
|
+
this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
|
|
877
899
|
} else {
|
|
878
900
|
this.#streamPrefixText = undefined;
|
|
879
901
|
this.#streamPrefixTokens = undefined;
|
|
902
|
+
this.#streamPrefixLineCache = undefined;
|
|
880
903
|
}
|
|
881
904
|
return tokens;
|
|
882
905
|
}
|
|
@@ -886,7 +909,7 @@ export class Markdown implements Component {
|
|
|
886
909
|
// render re-lexes only the unfrozen tail. Caller guarantees no CR / no
|
|
887
910
|
// reference definitions, so each token's `raw` is a verbatim slice of `text`
|
|
888
911
|
// and the summed offsets address `text` exactly.
|
|
889
|
-
#freezeStablePrefix(text: string, tokens: Token[]): void {
|
|
912
|
+
#freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
|
|
890
913
|
let pos = 0;
|
|
891
914
|
let frozenEnd = 0;
|
|
892
915
|
let frozenCount = 0;
|
|
@@ -915,8 +938,15 @@ export class Markdown implements Component {
|
|
|
915
938
|
if (next !== 0x20 /* space */ && next !== 0x0a /* \n */) {
|
|
916
939
|
this.#streamPrefixText = text.slice(0, frozenEnd);
|
|
917
940
|
this.#streamPrefixTokens = tokens.slice(0, frozenCount);
|
|
941
|
+
return;
|
|
918
942
|
}
|
|
919
943
|
}
|
|
944
|
+
|
|
945
|
+
if (!opts.preserveExisting) {
|
|
946
|
+
this.#streamPrefixText = undefined;
|
|
947
|
+
this.#streamPrefixTokens = undefined;
|
|
948
|
+
this.#streamPrefixLineCache = undefined;
|
|
949
|
+
}
|
|
920
950
|
}
|
|
921
951
|
|
|
922
952
|
render(width: number): readonly string[] {
|
|
@@ -942,6 +972,7 @@ export class Markdown implements Component {
|
|
|
942
972
|
|
|
943
973
|
// Replace tabs with 3 spaces for consistent rendering
|
|
944
974
|
const normalizedText = replaceTabs(this.#text);
|
|
975
|
+
const signature = this.#renderSignature(width, paddingX);
|
|
945
976
|
|
|
946
977
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
947
978
|
// session-tree navigations. Key encodes every dimension that affects the
|
|
@@ -957,9 +988,7 @@ export class Markdown implements Component {
|
|
|
957
988
|
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
|
958
989
|
let cacheKey: string | undefined;
|
|
959
990
|
if (!this.transientRenderCache) {
|
|
960
|
-
|
|
961
|
-
const headingProbe = this.#theme.heading("");
|
|
962
|
-
cacheKey = `${normalizedText}\x00${width}\x00${paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
|
991
|
+
cacheKey = this.#renderCacheKey(normalizedText, signature);
|
|
963
992
|
const cached = renderCache.get(cacheKey);
|
|
964
993
|
if (cached !== undefined) {
|
|
965
994
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
@@ -972,18 +1001,130 @@ export class Markdown implements Component {
|
|
|
972
1001
|
|
|
973
1002
|
// Parse markdown to HTML-like tokens
|
|
974
1003
|
const tokens = this.#lexTokens(normalizedText);
|
|
1004
|
+
const contentLines = this.transientRenderCache
|
|
1005
|
+
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
|
|
1006
|
+
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1007
|
+
const emptyLines = this.#renderEmptyPaddingLines(signature);
|
|
975
1008
|
|
|
976
|
-
//
|
|
977
|
-
const
|
|
1009
|
+
// Combine top padding, content, and bottom padding
|
|
1010
|
+
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
|
|
1011
|
+
const result = rawResult.length > 0 ? rawResult : [""];
|
|
978
1012
|
|
|
979
|
-
|
|
1013
|
+
// Update caches and hand the array out by reference. Callers must not
|
|
1014
|
+
// mutate it (Component render contract); the L2 entry is shared across
|
|
1015
|
+
// instances keyed on identical inputs.
|
|
1016
|
+
this.#cachedText = this.#text;
|
|
1017
|
+
this.#cachedWidth = width;
|
|
1018
|
+
this.#cachedLines = result;
|
|
1019
|
+
|
|
1020
|
+
// Update L2 module-level LRU so future instances with the same key skip
|
|
1021
|
+
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1022
|
+
if (cacheKey !== undefined) {
|
|
1023
|
+
renderCache.set(cacheKey, result);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
#renderSignature(width: number, paddingX: number): RenderSignature {
|
|
1030
|
+
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
1031
|
+
const headingProbe = this.#theme.heading("");
|
|
1032
|
+
return {
|
|
1033
|
+
width,
|
|
1034
|
+
paddingX,
|
|
1035
|
+
paddingY: this.#paddingY,
|
|
1036
|
+
codeBlockIndent: this.#codeBlockIndent,
|
|
1037
|
+
themeId: objectId(this.#theme),
|
|
1038
|
+
defaultTextStyleId: this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1,
|
|
1039
|
+
imageProtocol: TERMINAL.imageProtocol ?? "",
|
|
1040
|
+
hyperlinks: TERMINAL.hyperlinks,
|
|
1041
|
+
textSizing: TERMINAL.textSizing,
|
|
1042
|
+
bgColorProbe,
|
|
1043
|
+
headingProbe,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
#renderCacheKey(normalizedText: string, signature: RenderSignature): string {
|
|
1048
|
+
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}`;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
#renderStreamingContentLines(
|
|
1052
|
+
tokens: Token[],
|
|
1053
|
+
normalizedText: string,
|
|
1054
|
+
signature: RenderSignature,
|
|
1055
|
+
contentWidth: number,
|
|
1056
|
+
): string[] {
|
|
1057
|
+
const frozenText = this.#streamPrefixText;
|
|
1058
|
+
const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
|
|
1059
|
+
if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
|
|
1060
|
+
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const contentLines: string[] = [];
|
|
1064
|
+
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
|
|
1065
|
+
let renderedUntil = 0;
|
|
1066
|
+
if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
|
|
1067
|
+
contentLines.push(...reusablePrefix.lines);
|
|
1068
|
+
renderedUntil = reusablePrefix.tokenCount;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (renderedUntil < frozenTokenCount) {
|
|
1072
|
+
contentLines.push(
|
|
1073
|
+
...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
|
|
1074
|
+
);
|
|
1075
|
+
renderedUntil = frozenTokenCount;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
this.#streamPrefixLineCache = {
|
|
1079
|
+
...signature,
|
|
1080
|
+
text: frozenText,
|
|
1081
|
+
tokenCount: frozenTokenCount,
|
|
1082
|
+
lines: contentLines.slice(),
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
if (renderedUntil < tokens.length) {
|
|
1086
|
+
contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return contentLines;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
#matchingStreamPrefixLineCache(
|
|
1093
|
+
normalizedText: string,
|
|
1094
|
+
frozenText: string,
|
|
1095
|
+
signature: RenderSignature,
|
|
1096
|
+
): StreamPrefixLineCache | undefined {
|
|
1097
|
+
const cache = this.#streamPrefixLineCache;
|
|
1098
|
+
if (!cache) return undefined;
|
|
1099
|
+
if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
|
|
1100
|
+
if (cache.width !== signature.width) return undefined;
|
|
1101
|
+
if (cache.paddingX !== signature.paddingX) return undefined;
|
|
1102
|
+
if (cache.paddingY !== signature.paddingY) return undefined;
|
|
1103
|
+
if (cache.codeBlockIndent !== signature.codeBlockIndent) return undefined;
|
|
1104
|
+
if (cache.themeId !== signature.themeId) return undefined;
|
|
1105
|
+
if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return undefined;
|
|
1106
|
+
if (cache.imageProtocol !== signature.imageProtocol) return undefined;
|
|
1107
|
+
if (cache.hyperlinks !== signature.hyperlinks) return undefined;
|
|
1108
|
+
if (cache.textSizing !== signature.textSizing) return undefined;
|
|
1109
|
+
if (cache.bgColorProbe !== signature.bgColorProbe) return undefined;
|
|
1110
|
+
if (cache.headingProbe !== signature.headingProbe) return undefined;
|
|
1111
|
+
return cache;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
#renderContentLines(
|
|
1115
|
+
tokens: Token[],
|
|
1116
|
+
start: number,
|
|
1117
|
+
end: number,
|
|
1118
|
+
contentWidth: number,
|
|
1119
|
+
signature: RenderSignature,
|
|
1120
|
+
): string[] {
|
|
1121
|
+
const renderedLines: string[] = [];
|
|
1122
|
+
for (let i = start; i < end; i++) {
|
|
980
1123
|
const token = tokens[i];
|
|
981
1124
|
const nextToken = tokens[i + 1];
|
|
982
|
-
|
|
983
|
-
renderedLines.push(...tokenLines);
|
|
1125
|
+
renderedLines.push(...this.#renderToken(token, contentWidth, nextToken?.type));
|
|
984
1126
|
}
|
|
985
1127
|
|
|
986
|
-
// Wrap lines (NO padding, NO background yet)
|
|
987
1128
|
const wrappedLines: string[] = [];
|
|
988
1129
|
for (const line of renderedLines) {
|
|
989
1130
|
// Skip wrapping for image protocol lines and OSC 66 sized headings
|
|
@@ -995,12 +1136,10 @@ export class Markdown implements Component {
|
|
|
995
1136
|
}
|
|
996
1137
|
}
|
|
997
1138
|
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
const rightMargin = padding(paddingX);
|
|
1139
|
+
const leftMargin = padding(signature.paddingX);
|
|
1140
|
+
const rightMargin = padding(signature.paddingX);
|
|
1001
1141
|
const bgFn = this.#defaultTextStyle?.bgColor;
|
|
1002
1142
|
const contentLines: string[] = [];
|
|
1003
|
-
|
|
1004
1143
|
let previousLineWasOsc66 = false;
|
|
1005
1144
|
|
|
1006
1145
|
for (const line of wrappedLines) {
|
|
@@ -1023,45 +1162,30 @@ export class Markdown implements Component {
|
|
|
1023
1162
|
}
|
|
1024
1163
|
|
|
1025
1164
|
previousLineWasOsc66 = false;
|
|
1026
|
-
|
|
1027
1165
|
const lineWithMargins = leftMargin + line + rightMargin;
|
|
1028
1166
|
|
|
1029
1167
|
if (bgFn) {
|
|
1030
|
-
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
|
|
1168
|
+
contentLines.push(applyBackgroundToLine(lineWithMargins, signature.width, bgFn));
|
|
1031
1169
|
} else {
|
|
1032
1170
|
// No background - just pad to width
|
|
1033
1171
|
const visibleLen = visibleWidth(lineWithMargins);
|
|
1034
|
-
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
1172
|
+
const paddingNeeded = Math.max(0, signature.width - visibleLen);
|
|
1035
1173
|
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
1036
1174
|
}
|
|
1037
1175
|
}
|
|
1038
1176
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1177
|
+
return contentLines;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
#renderEmptyPaddingLines(signature: RenderSignature): string[] {
|
|
1181
|
+
const emptyLine = padding(signature.width);
|
|
1041
1182
|
const emptyLines: string[] = [];
|
|
1042
|
-
|
|
1043
|
-
|
|
1183
|
+
const bgFn = this.#defaultTextStyle?.bgColor;
|
|
1184
|
+
for (let i = 0; i < signature.paddingY; i++) {
|
|
1185
|
+
const line = bgFn ? applyBackgroundToLine(emptyLine, signature.width, bgFn) : emptyLine;
|
|
1044
1186
|
emptyLines.push(line);
|
|
1045
1187
|
}
|
|
1046
|
-
|
|
1047
|
-
// Combine top padding, content, and bottom padding
|
|
1048
|
-
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
|
|
1049
|
-
const result = rawResult.length > 0 ? rawResult : [""];
|
|
1050
|
-
|
|
1051
|
-
// Update caches and hand the array out by reference. Callers must not
|
|
1052
|
-
// mutate it (Component render contract); the L2 entry is shared across
|
|
1053
|
-
// instances keyed on identical inputs.
|
|
1054
|
-
this.#cachedText = this.#text;
|
|
1055
|
-
this.#cachedWidth = width;
|
|
1056
|
-
this.#cachedLines = result;
|
|
1057
|
-
|
|
1058
|
-
// Update L2 module-level LRU so future instances with the same key skip
|
|
1059
|
-
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1060
|
-
if (cacheKey !== undefined) {
|
|
1061
|
-
renderCache.set(cacheKey, result);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
return result;
|
|
1188
|
+
return emptyLines;
|
|
1065
1189
|
}
|
|
1066
1190
|
|
|
1067
1191
|
/**
|