@oh-my-pi/pi-tui 16.2.11 → 16.2.13

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.13] - 2026-07-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed fuzzy-search filtering for CJK and other non-ASCII queries by preserving Unicode letters and numbers during query normalization ([#4114](https://github.com/can1357/oh-my-pi/issues/4114)).
10
+
11
+ ## [16.2.12] - 2026-07-01
12
+
13
+ ### Fixed
14
+
15
+ - Optimized streaming markdown rendering to reuse already-rendered prefix lines and only render new content deltas, improving performance and reducing redraw flicker.
16
+
5
17
  ## [16.2.10] - 2026-06-30
6
18
 
7
19
  ### 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.11",
4
+ "version": "16.2.13",
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.11",
41
- "@oh-my-pi/pi-utils": "16.2.11",
40
+ "@oh-my-pi/pi-natives": "16.2.13",
41
+ "@oh-my-pi/pi-utils": "16.2.13",
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
- const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
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
- // Convert tokens to styled terminal output
977
- const renderedLines: string[] = [];
1009
+ // Combine top padding, content, and bottom padding
1010
+ const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
1011
+ const result = rawResult.length > 0 ? rawResult : [""];
978
1012
 
979
- for (let i = 0; i < tokens.length; i++) {
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
- const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
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
- // Add margins and background to each wrapped line
999
- const leftMargin = padding(paddingX);
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
- // Add top/bottom padding (empty lines)
1040
- const emptyLine = padding(width);
1177
+ return contentLines;
1178
+ }
1179
+
1180
+ #renderEmptyPaddingLines(signature: RenderSignature): string[] {
1181
+ const emptyLine = padding(signature.width);
1041
1182
  const emptyLines: string[] = [];
1042
- for (let i = 0; i < this.#paddingY; i++) {
1043
- const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
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
  /**
package/src/fuzzy.ts CHANGED
@@ -47,7 +47,7 @@ function normalizeForSearch(value: string): string {
47
47
  .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
48
48
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
49
49
  .toLowerCase()
50
- .replace(/[^a-z0-9]+/g, " ")
50
+ .replace(/[^\p{Letter}\p{Mark}\p{Number}]+/gu, " ")
51
51
  .trim()
52
52
  .replace(/\s+/g, " ");
53
53
  }