paseo-plugin-chat-view 0.0.0-stage → 0.1.0

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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.compact-agent-activity +21 -0
  3. package/LICENSE.mermaid +21 -0
  4. package/README.md +428 -2
  5. package/README_zh.md +429 -0
  6. package/client/activity/Activity.test.ts +220 -0
  7. package/client/activity/Activity.tsx +784 -0
  8. package/client/activity/detail/detail.test.ts +102 -0
  9. package/client/activity/detail/dispatch.tsx +94 -0
  10. package/client/activity/detail/file.tsx +93 -0
  11. package/client/activity/detail/index.tsx +39 -0
  12. package/client/activity/detail/parts.tsx +221 -0
  13. package/client/activity/detail/search.tsx +59 -0
  14. package/client/activity/detail/terminal.tsx +53 -0
  15. package/client/activity/detail/texts.tsx +42 -0
  16. package/client/activity/disclosure.ts +49 -0
  17. package/client/activity/dom.test.ts +52 -0
  18. package/client/activity/dom.ts +138 -0
  19. package/client/activity/group-store.test.ts +126 -0
  20. package/client/activity/group-store.ts +265 -0
  21. package/client/activity/highlight.test.ts +116 -0
  22. package/client/activity/highlight.ts +418 -0
  23. package/client/activity/history.test.ts +602 -0
  24. package/client/activity/history.ts +239 -0
  25. package/client/activity/index.ts +74 -0
  26. package/client/activity/latest-marker.test.ts +41 -0
  27. package/client/activity/latest-marker.ts +70 -0
  28. package/client/activity/styles.ts +535 -0
  29. package/client/activity/tools/child-agent.tsx +308 -0
  30. package/client/activity/tools/exa.tsx +99 -0
  31. package/client/activity/tools/github.tsx +501 -0
  32. package/client/activity/tools/list.tsx +102 -0
  33. package/client/activity/tools/lists.test.ts +193 -0
  34. package/client/activity/tools/paseo/agent.tsx +384 -0
  35. package/client/activity/tools/paseo/browser.tsx +322 -0
  36. package/client/activity/tools/paseo/index.tsx +79 -0
  37. package/client/activity/tools/paseo/parts.tsx +133 -0
  38. package/client/activity/tools/paseo/provider.tsx +220 -0
  39. package/client/activity/tools/paseo/schedule.tsx +246 -0
  40. package/client/activity/tools/paseo/speak.tsx +20 -0
  41. package/client/activity/tools/paseo/terminal.tsx +214 -0
  42. package/client/activity/tools/paseo/workspace.tsx +162 -0
  43. package/client/activity/tools/shared.tsx +431 -0
  44. package/client/activity/transform.test.ts +28 -0
  45. package/client/activity/transform.ts +43 -0
  46. package/client/agent-pills.ts +56 -0
  47. package/client/dom.ts +75 -0
  48. package/client/enhance/controller.ts +111 -0
  49. package/client/enhance/dom.test.ts +28 -0
  50. package/client/enhance/dom.ts +101 -0
  51. package/client/enhance/index.ts +95 -0
  52. package/client/enhance/pill.tsx +19 -0
  53. package/client/enhance/state.ts +58 -0
  54. package/client/feature.ts +22 -0
  55. package/client/features.test.ts +149 -0
  56. package/client/features.ts +83 -0
  57. package/client/locale.ts +45 -0
  58. package/client/mermaid/diagram.tsx +438 -0
  59. package/client/mermaid/index.ts +27 -0
  60. package/client/mermaid/item.tsx +28 -0
  61. package/client/mermaid/markdown.tsx +217 -0
  62. package/client/mermaid/registration.ts +32 -0
  63. package/client/mermaid/toolbar.tsx +175 -0
  64. package/client/mermaid/viewer.tsx +314 -0
  65. package/client/readonly/dom.test.ts +66 -0
  66. package/client/readonly/dom.ts +32 -0
  67. package/client/readonly/index.ts +27 -0
  68. package/client/registration.test.ts +243 -0
  69. package/client/settings.tsx +438 -0
  70. package/client/sticky/StickyBar.tsx +144 -0
  71. package/client/sticky/dom.test.ts +230 -0
  72. package/client/sticky/dom.ts +517 -0
  73. package/client/sticky/index.ts +38 -0
  74. package/client/sticky/pill.tsx +70 -0
  75. package/client/timeline-pager.test.ts +121 -0
  76. package/client/timeline-pager.ts +123 -0
  77. package/index.client.tsx +44 -0
  78. package/index.server.ts +12 -0
  79. package/package.json +42 -4
  80. package/paseo-plugin.json +6 -0
  81. package/server/enhance/index.ts +63 -0
  82. package/server/enhance/manifest.ts +176 -0
  83. package/server/enhance/probe.test.ts +215 -0
  84. package/server/enhance/probe.ts +118 -0
  85. package/server/enhance/provider.ts +149 -0
  86. package/shared/activity/child-agent.test.ts +27 -0
  87. package/shared/activity/child-agent.ts +26 -0
  88. package/shared/activity/diff.test.ts +74 -0
  89. package/shared/activity/diff.ts +207 -0
  90. package/shared/activity/exa.test.ts +66 -0
  91. package/shared/activity/exa.ts +183 -0
  92. package/shared/activity/file-kind.test.ts +13 -0
  93. package/shared/activity/file-kind.ts +112 -0
  94. package/shared/activity/github.test.ts +56 -0
  95. package/shared/activity/github.ts +174 -0
  96. package/shared/activity/palette.test.ts +26 -0
  97. package/shared/activity/palette.ts +106 -0
  98. package/shared/activity/parse.test.ts +39 -0
  99. package/shared/activity/parse.ts +115 -0
  100. package/shared/activity/paseo-tools.test.ts +163 -0
  101. package/shared/activity/paseo-tools.ts +497 -0
  102. package/shared/activity/text.test.ts +85 -0
  103. package/shared/activity/text.ts +202 -0
  104. package/shared/activity/timeline.test.ts +26 -0
  105. package/shared/activity/timeline.ts +108 -0
  106. package/shared/activity/tool-presentation.test.ts +154 -0
  107. package/shared/activity/tool-presentation.ts +352 -0
  108. package/shared/enhance/contract.ts +105 -0
  109. package/shared/enhance/decide.ts +97 -0
  110. package/shared/enhance/enhance.test.ts +188 -0
  111. package/shared/enhance/failure.ts +40 -0
  112. package/shared/enhance/index.ts +8 -0
  113. package/shared/enhance/normalize.ts +16 -0
  114. package/shared/enhance/parse.ts +79 -0
  115. package/shared/enhance/policy.ts +48 -0
  116. package/shared/enhance/prompt.ts +134 -0
  117. package/shared/enhance/run.ts +84 -0
  118. package/shared/i18n.test.ts +81 -0
  119. package/shared/i18n.ts +300 -0
  120. package/shared/mermaid/flowchart.test.ts +169 -0
  121. package/shared/mermaid/flowchart.ts +271 -0
  122. package/shared/mermaid/layout.test.ts +305 -0
  123. package/shared/mermaid/layout.ts +496 -0
  124. package/shared/mermaid/parse.test.ts +110 -0
  125. package/shared/mermaid/parse.ts +193 -0
  126. package/shared/mermaid/segment.test.ts +55 -0
  127. package/shared/mermaid/segment.ts +95 -0
  128. package/shared/mermaid/sequence.test.ts +68 -0
  129. package/shared/mermaid/sequence.ts +130 -0
  130. package/shared/mermaid/transform.test.ts +52 -0
  131. package/shared/mermaid/transform.ts +45 -0
  132. package/shared/mermaid/zoom.test.ts +134 -0
  133. package/shared/mermaid/zoom.ts +114 -0
  134. package/shared/settings.ts +41 -0
  135. package/shared/sticky/history.test.ts +91 -0
  136. package/shared/sticky/history.ts +86 -0
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import React from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { highlightCode, useHighlightTokens, MAX_HIGHLIGHT_CHARS } from "./highlight";
5
+
6
+ describe("Syntax highlighting", () => {
7
+ it("renders command tokens on first paint and after a history row remount", () => {
8
+ function Command() {
9
+ const tokens = useHighlightTokens("npm test # remounted command", "bash", true);
10
+ return React.createElement(
11
+ "span",
12
+ { "data-highlighted": tokens !== null },
13
+ tokens
14
+ ?.flat()
15
+ .map((token) => token.content)
16
+ .join(""),
17
+ );
18
+ }
19
+ for (let mount = 0; mount < 2; mount++) {
20
+ const markup = renderToStaticMarkup(React.createElement(Command));
21
+ expect(markup).toContain('data-highlighted="true"');
22
+ expect(markup).toContain("npm test # remounted command");
23
+ }
24
+ });
25
+ it("tokenizes shell commands without HTML or DOM APIs", async () => {
26
+ const tokens = await highlightCode("$ bun run test\n170 pass", "bash", true);
27
+ const content = tokens
28
+ ?.flat()
29
+ .map((token) => token.content)
30
+ .join("");
31
+ expect(content).toContain("bun");
32
+ expect(content).toContain("170 pass");
33
+ expect(tokens?.flat().some((token) => token.color)).toBe(true);
34
+ });
35
+
36
+ it("tokenizes arbitrary JSON payloads", async () => {
37
+ const tokens = await highlightCode('{\n "content": [{"type": "text"}]\n}', "json", true);
38
+ const content = tokens
39
+ ?.flat()
40
+ .map((token) => token.content)
41
+ .join("");
42
+ expect(content).toContain('"content"');
43
+ expect(content).toContain('"text"');
44
+ expect(tokens?.flat().some((token) => token.color)).toBe(true);
45
+ });
46
+
47
+ it("tokenizes ANSI terminal output with color sequences", async () => {
48
+ const tokens = await highlightCode(
49
+ "Normal \u001b[32mPASS\u001b[0m \u001b[38;2;255;0;0mFAIL\u001b[0m",
50
+ "ansi",
51
+ true,
52
+ );
53
+ const content = tokens
54
+ ?.flat()
55
+ .map((token) => token.content)
56
+ .join("");
57
+ expect(content).toBe("Normal PASS FAIL");
58
+ expect(tokens?.flat().some((token) => token.color)).toBe(true);
59
+ expect(tokens?.flat().find((token) => token.content === "FAIL")?.color).toBe("rgb(255,0,0)");
60
+ });
61
+
62
+ it("returns a fallback signal for oversized output", async () => {
63
+ await expect(
64
+ highlightCode("x".repeat(MAX_HIGHLIGHT_CHARS + 1), "ansi", true),
65
+ ).resolves.toBeNull();
66
+ });
67
+
68
+ it("maps syntax tokens to custom theme colors", async () => {
69
+ const themeColors = {
70
+ foreground: "#ffffff",
71
+ foregroundMuted: "#888888",
72
+ accent: "#00aaff",
73
+ statusSuccess: "#00ff88",
74
+ statusWarning: "#ffaa00",
75
+ statusDanger: "#ff4444",
76
+ };
77
+ const code = 'const count = 42; // comment\nconst name = "hello";';
78
+ const tokens = (await highlightCode(code, "javascript", themeColors))?.flat();
79
+ expect(tokens).toBeDefined();
80
+
81
+ const commentToken = tokens?.find((t) => t.content === "// comment");
82
+ expect(commentToken?.color).toBe(themeColors.foregroundMuted);
83
+
84
+ const stringToken = tokens?.find((t) => t.content === '"hello"');
85
+ expect(stringToken?.color).toBe(themeColors.statusSuccess);
86
+
87
+ const keywordToken = tokens?.find((t) => t.content === "const");
88
+ expect(keywordToken?.color).toBe(themeColors.accent);
89
+
90
+ const numberToken = tokens?.find((t) => t.content === "42");
91
+ expect(numberToken?.color).toBe(themeColors.statusWarning);
92
+ });
93
+
94
+ it("colors bash strings, comments, variables, and keywords with the lightweight grammar", async () => {
95
+ const themeColors = {
96
+ foreground: "#ffffff",
97
+ foregroundMuted: "#888888",
98
+ accent: "#00aaff",
99
+ statusSuccess: "#00ff88",
100
+ statusWarning: "#ffaa00",
101
+ statusDanger: "#ff4444",
102
+ };
103
+ const code = 'if [ -f "$FILE" ]; then\n echo "found" # check file\nfi';
104
+ const tokens = (await highlightCode(code, "bash", themeColors))?.flat();
105
+ expect(tokens).toBeDefined();
106
+
107
+ const comment = tokens?.find((t) => t.content.includes("# check file"));
108
+ expect(comment?.color).toBe(themeColors.foregroundMuted);
109
+
110
+ const str = tokens?.find((t) => t.content === '"found"');
111
+ expect(str?.color).toBe(themeColors.statusSuccess);
112
+
113
+ const keyword = tokens?.find((t) => t.content === "if");
114
+ expect(keyword?.color).toBe(themeColors.accent);
115
+ });
116
+ });
@@ -0,0 +1,418 @@
1
+ // @ts-expect-error -- prismjs does not declare default export for prism-core
2
+ import Prism from "prismjs/components/prism-core";
3
+ import "prismjs/components/prism-markup";
4
+ import "prismjs/components/prism-css";
5
+ import "prismjs/components/prism-clike";
6
+ import "prismjs/components/prism-javascript";
7
+ import "prismjs/components/prism-typescript";
8
+ import "prismjs/components/prism-jsx";
9
+ import "prismjs/components/prism-tsx";
10
+ import "prismjs/components/prism-python";
11
+ import "prismjs/components/prism-json";
12
+ import "prismjs/components/prism-yaml";
13
+
14
+ import { parseHexColor } from "../../shared/activity/palette";
15
+ import "prismjs/components/prism-markdown";
16
+ import "prismjs/components/prism-go";
17
+ import "prismjs/components/prism-rust";
18
+ import { useMemo } from "react";
19
+
20
+ // Lightweight regex grammar for Bash (avoids large stateful prism-bash dependency)
21
+ Prism.languages.bash = {
22
+ comment: { pattern: /(^|[\t ])#.*/m, lookbehind: true, greedy: true },
23
+ string: { pattern: /"(?:\\[\s\S]|[^"\\])*"|'[^']*'/, greedy: true },
24
+ variable: /\$(?:\w+|\{[^}\r\n]*\}|[?$!#@*-])/,
25
+ keyword:
26
+ /\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|return|export|local)\b/,
27
+ builtin:
28
+ /\b(?:test|echo|cd|pwd|ls|cat|mkdir|rm|cp|mv|touch|grep|find|sed|awk|curl|git|node|npm|bun|deno)\b/,
29
+ operator: /&&|\|\||[|&;<>]/,
30
+ punctuation: /[(){}[\]]/,
31
+ };
32
+
33
+ export interface ThemeSyntaxColors {
34
+ foreground: string;
35
+ foregroundMuted: string;
36
+ accent: string;
37
+ surface1?: string;
38
+ statusSuccess?: string;
39
+ statusWarning?: string;
40
+ statusDanger?: string;
41
+ }
42
+
43
+ export type SyntaxColorsInput = ThemeSyntaxColors | boolean;
44
+
45
+ const DEFAULT_DARK_COLORS: ThemeSyntaxColors = {
46
+ foreground: "#e6edf3",
47
+ foregroundMuted: "#8b949e",
48
+ accent: "#58a6ff",
49
+ statusSuccess: "#7ee787",
50
+ statusWarning: "#d29922",
51
+ statusDanger: "#ff7b72",
52
+ };
53
+
54
+ const DEFAULT_LIGHT_COLORS: ThemeSyntaxColors = {
55
+ foreground: "#1f2328",
56
+ foregroundMuted: "#656d76",
57
+ accent: "#0969da",
58
+ statusSuccess: "#1a7f37",
59
+ statusWarning: "#9a6700",
60
+ statusDanger: "#cf222e",
61
+ };
62
+
63
+ export function resolveSyntaxColors(input: SyntaxColorsInput): ThemeSyntaxColors {
64
+ if (typeof input === "boolean") {
65
+ return input ? DEFAULT_DARK_COLORS : DEFAULT_LIGHT_COLORS;
66
+ }
67
+ return input;
68
+ }
69
+
70
+ export interface HighlightToken {
71
+ content: string;
72
+ color?: string;
73
+ fontStyle?: number;
74
+ }
75
+
76
+ export const MAX_HIGHLIGHT_CHARS = 100_000;
77
+
78
+ function parseAnsiToLines(code: string, colors: ThemeSyntaxColors): HighlightToken[][] {
79
+ const lines: HighlightToken[][] = [];
80
+ // eslint-disable-next-line no-control-regex
81
+ const ansiRegex = /\u001b\[([0-9;]*)m/g;
82
+ const ansiColors: Record<number, string> = {
83
+ 30: colors.foregroundMuted,
84
+ 31: colors.statusDanger ?? "#ff7b72",
85
+ 32: colors.statusSuccess ?? "#7ee787",
86
+ 33: colors.statusWarning ?? "#d29922",
87
+ 34: colors.accent,
88
+ 35: colors.accent,
89
+ 36: colors.accent,
90
+ 37: colors.foreground,
91
+ 90: colors.foregroundMuted,
92
+ 91: colors.statusDanger ?? "#ff7b72",
93
+ 92: colors.statusSuccess ?? "#7ee787",
94
+ 93: colors.statusWarning ?? "#d29922",
95
+ 94: colors.accent,
96
+ 95: colors.accent,
97
+ 96: colors.accent,
98
+ 97: colors.foreground,
99
+ };
100
+ let currentColor: string | undefined;
101
+ let currentFontStyle = 0;
102
+
103
+ for (const rawLine of code.split("\n")) {
104
+ const currentLine: HighlightToken[] = [];
105
+ lines.push(currentLine);
106
+ let lastIndex = 0;
107
+ let match: RegExpExecArray | null;
108
+ ansiRegex.lastIndex = 0;
109
+
110
+ while ((match = ansiRegex.exec(rawLine)) !== null) {
111
+ const textBefore = rawLine.slice(lastIndex, match.index);
112
+ if (textBefore.length > 0) {
113
+ currentLine.push({
114
+ content: textBefore,
115
+ color: currentColor,
116
+ fontStyle: currentFontStyle || undefined,
117
+ });
118
+ }
119
+ lastIndex = ansiRegex.lastIndex;
120
+ const codes = match[1] ? match[1].split(";").map(Number) : [0];
121
+ let idx = 0;
122
+ while (idx < codes.length) {
123
+ const c = codes[idx]!;
124
+ if (c === 0) {
125
+ currentColor = undefined;
126
+ currentFontStyle = 0;
127
+ idx++;
128
+ } else if (c === 1) {
129
+ currentFontStyle |= 2;
130
+ idx++;
131
+ } else if (c === 3) {
132
+ currentFontStyle |= 1;
133
+ idx++;
134
+ } else if (c === 4) {
135
+ currentFontStyle |= 4;
136
+ idx++;
137
+ } else if (c === 22) {
138
+ currentFontStyle &= ~2;
139
+ idx++;
140
+ } else if (c === 23) {
141
+ currentFontStyle &= ~1;
142
+ idx++;
143
+ } else if (c === 24) {
144
+ currentFontStyle &= ~4;
145
+ idx++;
146
+ } else if (c === 39) {
147
+ currentColor = undefined;
148
+ idx++;
149
+ } else if (c === 38 || c === 48) {
150
+ const isFg = c === 38;
151
+ const mode = codes[idx + 1];
152
+ if (mode === 5) {
153
+ const colorIdx = codes[idx + 2];
154
+ if (colorIdx !== undefined && isFg) {
155
+ if (colorIdx < 16) {
156
+ currentColor = ansiColors[colorIdx < 8 ? 30 + colorIdx : 90 + (colorIdx - 8)];
157
+ } else {
158
+ currentColor = colors.foreground;
159
+ }
160
+ }
161
+ idx += 3;
162
+ } else if (mode === 2) {
163
+ const r = codes[idx + 2];
164
+ const g = codes[idx + 3];
165
+ const b = codes[idx + 4];
166
+ if (r !== undefined && g !== undefined && b !== undefined && isFg) {
167
+ currentColor = `rgb(${r},${g},${b})`;
168
+ }
169
+ idx += 5;
170
+ } else {
171
+ idx++;
172
+ }
173
+ } else if (ansiColors[c]) {
174
+ currentColor = ansiColors[c];
175
+ idx++;
176
+ } else {
177
+ idx++;
178
+ }
179
+ }
180
+ }
181
+ const remaining = rawLine.slice(lastIndex);
182
+ if (remaining.length > 0) {
183
+ currentLine.push({
184
+ content: remaining,
185
+ color: currentColor,
186
+ fontStyle: currentFontStyle || undefined,
187
+ });
188
+ }
189
+ }
190
+
191
+ return lines;
192
+ }
193
+
194
+ function resolveTokenColor(type: string, colors: ThemeSyntaxColors): string {
195
+ switch (type) {
196
+ case "comment":
197
+ case "prolog":
198
+ case "doctype":
199
+ case "cdata":
200
+ case "punctuation":
201
+ case "operator":
202
+ return colors.foregroundMuted;
203
+
204
+ case "string":
205
+ case "char":
206
+ case "attr-value":
207
+ case "regex":
208
+ case "template-string":
209
+ return colors.statusSuccess ?? colors.accent;
210
+
211
+ case "keyword":
212
+ case "selector":
213
+ case "important":
214
+ case "atrule":
215
+ case "boolean":
216
+ return colors.accent;
217
+
218
+ case "number":
219
+ case "constant":
220
+ return colors.statusWarning ?? colors.accent;
221
+
222
+ case "function":
223
+ case "function-variable":
224
+ case "method":
225
+ case "builtin":
226
+ case "class-name":
227
+ return colors.accent;
228
+
229
+ case "variable":
230
+ case "property":
231
+ case "tag":
232
+ case "attr-name":
233
+ default:
234
+ return colors.foreground;
235
+ }
236
+ }
237
+
238
+ function tokenizeToLines(
239
+ code: string,
240
+ grammar: Prism.Grammar,
241
+ colors: ThemeSyntaxColors,
242
+ ): HighlightToken[][] {
243
+ const rawTokens = Prism.tokenize(code, grammar);
244
+ const lines: HighlightToken[][] = [[]];
245
+
246
+ function pushText(content: string, type?: string) {
247
+ const parts = content.split("\n");
248
+ for (let i = 0; i < parts.length; i++) {
249
+ if (i > 0) lines.push([]);
250
+ const part = parts[i];
251
+ if (part && part.length > 0) {
252
+ const currentLine = lines[lines.length - 1];
253
+ if (currentLine) {
254
+ currentLine.push({
255
+ content: part,
256
+ color: type ? resolveTokenColor(type, colors) : colors.foreground,
257
+ fontStyle: type === "comment" ? 1 : undefined,
258
+ });
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ function walk(token: string | Prism.Token | (string | Prism.Token)[], parentType?: string) {
265
+ if (typeof token === "string") {
266
+ pushText(token, parentType);
267
+ } else if (Array.isArray(token)) {
268
+ for (const item of token) walk(item, parentType);
269
+ } else if (token && typeof token === "object") {
270
+ const type = token.type || parentType;
271
+ if (typeof token.content === "string") {
272
+ pushText(token.content, type);
273
+ } else {
274
+ walk(token.content, type);
275
+ }
276
+ }
277
+ }
278
+
279
+ for (const token of rawTokens) walk(token);
280
+ return lines;
281
+ }
282
+
283
+ const tokenCache = new Map<string, { tokens: HighlightToken[][]; size: number }>();
284
+ const TOKEN_CACHE_LIMIT = 40;
285
+ let cachedChars = 0;
286
+
287
+ function colorsKey(colors: ThemeSyntaxColors): string {
288
+ return [
289
+ colors.foreground,
290
+ colors.foregroundMuted,
291
+ colors.accent,
292
+ colors.statusSuccess ?? "",
293
+ colors.statusWarning ?? "",
294
+ colors.statusDanger ?? "",
295
+ ].join("|");
296
+ }
297
+
298
+ /** FNV-1a over the source, so a streaming block does not allocate its whole body into a map key. */
299
+ function hashCode(code: string): string {
300
+ let hash = 0x811c9dc5;
301
+ for (let index = 0; index < code.length; index++) {
302
+ hash ^= code.charCodeAt(index);
303
+ hash = Math.imul(hash, 0x01000193);
304
+ }
305
+ return (hash >>> 0).toString(36);
306
+ }
307
+
308
+ function cacheKey(code: string, language: string, colors: ThemeSyntaxColors): string {
309
+ return `${colorsKey(colors)}:${language}:${code.length}:${hashCode(code)}`;
310
+ }
311
+
312
+ function cacheTokens(key: string, tokens: HighlightToken[][], codeLength: number): void {
313
+ if (tokenCache.has(key)) {
314
+ const existing = tokenCache.get(key)!;
315
+ cachedChars -= existing.size;
316
+ tokenCache.delete(key);
317
+ }
318
+ tokenCache.set(key, { tokens, size: codeLength });
319
+ cachedChars += codeLength;
320
+
321
+ while (tokenCache.size > TOKEN_CACHE_LIMIT || cachedChars > 500_000) {
322
+ const oldestKey = tokenCache.keys().next().value;
323
+ if (oldestKey === undefined) break;
324
+ const oldest = tokenCache.get(oldestKey);
325
+ if (oldest) cachedChars -= oldest.size;
326
+ tokenCache.delete(oldestKey);
327
+ }
328
+ }
329
+
330
+ export function isDarkSurface(color: string): boolean {
331
+ const rgb = parseHexColor(color);
332
+ if (!rgb) return true;
333
+ const channels = rgb.map((channel) => {
334
+ const normalized = channel / 255;
335
+ return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
336
+ });
337
+ const luminance =
338
+ 0.2126 * (channels[0] ?? 0) + 0.7152 * (channels[1] ?? 0) + 0.0722 * (channels[2] ?? 0);
339
+ return luminance < 0.5;
340
+ }
341
+
342
+ function normalizeLanguage(language: string): string | null {
343
+ const normalized = language.trim().toLowerCase();
344
+ if (!normalized) return null;
345
+ if (normalized === "shell" || normalized === "sh") return "bash";
346
+ if (normalized === "ansi") return "ansi";
347
+ if (normalized === "text" || normalized === "plain") return "text";
348
+ const aliases: Record<string, string> = {
349
+ js: "javascript",
350
+ jsx: "jsx",
351
+ md: "markdown",
352
+ py: "python",
353
+ sh: "bash",
354
+ ts: "typescript",
355
+ tsx: "tsx",
356
+ yml: "yaml",
357
+ html: "markup",
358
+ xml: "markup",
359
+ };
360
+ const resolved = aliases[normalized] ?? normalized;
361
+ return resolved in Prism.languages ? resolved : null;
362
+ }
363
+
364
+ export function highlightCodeSync(
365
+ code: string,
366
+ language: string,
367
+ colorsInput: SyntaxColorsInput,
368
+ ): HighlightToken[][] | null {
369
+ if (!code || code.length > MAX_HIGHLIGHT_CHARS) return null;
370
+ const normalizedLanguage = normalizeLanguage(language);
371
+ if (!normalizedLanguage) return null;
372
+
373
+ const colors = resolveSyntaxColors(colorsInput);
374
+ const key = cacheKey(code, normalizedLanguage, colors);
375
+ const cached = tokenCache.get(key);
376
+ if (cached) {
377
+ tokenCache.delete(key);
378
+ tokenCache.set(key, cached);
379
+ return cached.tokens;
380
+ }
381
+
382
+ let tokens: HighlightToken[][];
383
+ if (normalizedLanguage === "ansi") {
384
+ tokens = parseAnsiToLines(code, colors);
385
+ } else if (normalizedLanguage === "text") {
386
+ tokens = code.split("\n").map((line) => [{ content: line }]);
387
+ } else {
388
+ const grammar = Prism.languages[normalizedLanguage];
389
+ if (!grammar) return null;
390
+ tokens = tokenizeToLines(code, grammar, colors);
391
+ }
392
+
393
+ cacheTokens(key, tokens, code.length);
394
+ return tokens;
395
+ }
396
+
397
+ export async function highlightCode(
398
+ code: string,
399
+ language: string,
400
+ colorsInput: SyntaxColorsInput,
401
+ ): Promise<HighlightToken[][] | null> {
402
+ return highlightCodeSync(code, language, colorsInput);
403
+ }
404
+
405
+ export function useHighlightTokens(
406
+ code: string | undefined,
407
+ language: string | undefined,
408
+ colorsInput: SyntaxColorsInput,
409
+ ): HighlightToken[][] | null {
410
+ const colors = resolveSyntaxColors(colorsInput);
411
+ const key = colorsKey(colors);
412
+ // History rows must mount with their final token tree, rather than first painting plain text
413
+ // and swapping it after an effect. highlightCodeSync reuses the bounded token cache.
414
+ return useMemo(
415
+ () => (code && language ? highlightCodeSync(code, language, colors) : null),
416
+ [code, language, key],
417
+ );
418
+ }