@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.1

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 (56) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +116 -0
  3. package/README.md +292 -2
  4. package/markdown/build/build-markdown.cjs +66 -0
  5. package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
  6. package/markdown/dist/markdown.css +1 -0
  7. package/markdown/dist/markdown.mjs +325 -0
  8. package/markdown/source/markdown-factory.mjs +613 -0
  9. package/markdown/source/markdown.css +288 -0
  10. package/markdown/source/table-factory.mjs +1588 -0
  11. package/markdown/source/table.css +487 -0
  12. package/package.json +96 -4
  13. package/src/activity-groups.mjs +74 -0
  14. package/src/browser-response-groups.mjs +57 -0
  15. package/src/command-output.mjs +57 -0
  16. package/src/components.mjs +9 -0
  17. package/src/context-markers.mjs +19 -0
  18. package/src/context-status.mjs +34 -0
  19. package/src/diff.mjs +240 -0
  20. package/src/explore-tool-call.mjs +139 -0
  21. package/src/file-preview.mjs +50 -0
  22. package/src/host/native-a2a.mjs +90 -0
  23. package/src/host/native-child-file-pane.mjs +49 -0
  24. package/src/host/native-child-pane.mjs +94 -0
  25. package/src/host/native-command-pane.mjs +64 -0
  26. package/src/host/native-run-panel.mjs +202 -0
  27. package/src/host/session-task-events.mjs +247 -0
  28. package/src/host/state/native-a2a.mjs +122 -0
  29. package/src/host/state/native-child-observation.mjs +35 -0
  30. package/src/host/state/native-children.mjs +93 -0
  31. package/src/host/state/native-outcome.mjs +35 -0
  32. package/src/host/state/native-recovery.mjs +187 -0
  33. package/src/host/state/native-runtime-route.mjs +26 -0
  34. package/src/host/state/native-session-stop.mjs +40 -0
  35. package/src/host/state/native-status-panel.mjs +23 -0
  36. package/src/host/state/native-task-read-model.mjs +89 -0
  37. package/src/host/state/task-links.mjs +40 -0
  38. package/src/host/task-timeline.mjs +185 -0
  39. package/src/index.mjs +52 -0
  40. package/src/input-preview.mjs +44 -0
  41. package/src/inspector.mjs +40 -0
  42. package/src/project.mjs +132 -0
  43. package/src/protocol.mjs +362 -0
  44. package/src/react-binding.mjs +486 -0
  45. package/src/reducer.mjs +355 -0
  46. package/src/renderers.mjs +644 -0
  47. package/src/status-panel.mjs +1156 -0
  48. package/src/styles.mjs +278 -0
  49. package/src/summary-playback.mjs +39 -0
  50. package/src/summary.mjs +160 -0
  51. package/src/terminal.mjs +157 -0
  52. package/src/tool-detail.mjs +208 -0
  53. package/src/tool-error.mjs +33 -0
  54. package/src/tool-identity.mjs +40 -0
  55. package/src/view.mjs +364 -0
  56. package/src/work-segments.mjs +41 -0
@@ -0,0 +1,613 @@
1
+ // @kingsoftcloud/agentkit-harness-ui/markdown — Markdown rendering layer factory
2
+ //
3
+ // A host-injected-React factory that renders streaming/complete assistant
4
+ // Markdown through a statically bundled Streamdown 2.5.0 + @streamdown/cjk
5
+ // 1.0.3 pipeline, with a STABLE, independent code block that highlights via
6
+ // @shikijs/core 3.23.0 + the JS regex engine + a registered set of common
7
+ // languages and two themes — NOT Streamdown's default code renderer — and a
8
+ // STABLE, independent Markdown table component (table-factory.mjs, ported from
9
+ // the table renderer) that owns the toolbar (copy Markdown / download
10
+ // CSV / fullscreen preview / wide-table expand-collapse), the horizontal
11
+ // scroll viewport with edge shadows, the hover virtual scrollbar and the
12
+ // fullscreen preview dialog — NOT Streamdown's default table renderer.
13
+ //
14
+ // React, ReactDOM and the JSX runtime are NOT bundled: the build
15
+ // (../build/build-markdown.cjs via esbuild) marks `react`, `react-dom` and
16
+ // `react/jsx-runtime` external, so the dist CJS `require()`s them from the
17
+ // host at runtime. The factory uses ONLY the React/ReactDOM instances passed
18
+ // into createMarkdownComponents(), which the host must keep identical to the
19
+ // ones resolved by those `require()` calls (see verifyReactInjection).
20
+ //
21
+ // Streaming vs. complete (desktop MessageResponse / code-block.tsx React #185
22
+ // avoidance): while `streaming` is true, code blocks render raw text with no
23
+ // async highlighter, and Streamdown runs in `mode="streaming"` with
24
+ // `parseIncompleteMarkdown`. When `streaming` flips false, the stable CodeBlock
25
+ // highlights the (now stable) source. No timer fakes text; no setState races
26
+ // between the streaming body and the highlighter.
27
+ //
28
+ // No eval / new Function; no Studio/DSH/Node-only imports at runtime; no wasm;
29
+ // no oniguruma; no second React copy.
30
+
31
+ import { Streamdown, defaultRehypePlugins, defaultRemarkPlugins } from "streamdown";
32
+ import { cjk as cjkPlugin } from "@streamdown/cjk";
33
+ import { createHighlighterCore } from "@shikijs/core";
34
+ import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
35
+ import githubLight from "@shikijs/themes/github-light";
36
+ import githubDark from "@shikijs/themes/github-dark";
37
+ import langJavascript from "@shikijs/langs/javascript";
38
+ import langTypescript from "@shikijs/langs/typescript";
39
+ import langJsx from "@shikijs/langs/jsx";
40
+ import langTsx from "@shikijs/langs/tsx";
41
+ import langJson from "@shikijs/langs/json";
42
+ import langPython from "@shikijs/langs/python";
43
+ import langShellscript from "@shikijs/langs/shellscript";
44
+ import langCss from "@shikijs/langs/css";
45
+ import langHtml from "@shikijs/langs/html";
46
+ import langMarkdown from "@shikijs/langs/markdown";
47
+ import langYaml from "@shikijs/langs/yaml";
48
+ import langSql from "@shikijs/langs/sql";
49
+ import langGo from "@shikijs/langs/go";
50
+ import langRust from "@shikijs/langs/rust";
51
+ import langDiff from "@shikijs/langs/diff";
52
+ import langJava from "@shikijs/langs/java";
53
+ import langBash from "@shikijs/langs/bash";
54
+
55
+ import { createMarkdownTableComponents } from "./table-factory.mjs";
56
+
57
+ const LIGHT_THEME = "github-light";
58
+ const DARK_THEME = "github-dark";
59
+
60
+ // Registered grammars. Unknown languages fall back to plain text (no
61
+ // highlighting) so the bundle stays bounded — only these grammars ship.
62
+ const REGISTERED_LANGS = {
63
+ javascript: langJavascript,
64
+ typescript: langTypescript,
65
+ jsx: langJsx,
66
+ tsx: langTsx,
67
+ json: langJson,
68
+ python: langPython,
69
+ shellscript: langShellscript,
70
+ css: langCss,
71
+ html: langHtml,
72
+ markdown: langMarkdown,
73
+ yaml: langYaml,
74
+ sql: langSql,
75
+ go: langGo,
76
+ rust: langRust,
77
+ diff: langDiff,
78
+ java: langJava,
79
+ bash: langBash,
80
+ };
81
+
82
+ // Aliases map to a registered id. Anything not here and not a registered id
83
+ // resolves to plaintext.
84
+ const LANG_ALIASES = {
85
+ js: "javascript",
86
+ cjs: "javascript",
87
+ mjs: "javascript",
88
+ ts: "typescript",
89
+ py: "python",
90
+ sh: "shellscript",
91
+ shell: "shellscript",
92
+ zsh: "shellscript",
93
+ "sh-session": "shellscript",
94
+ console: "shellscript",
95
+ bash: "bash",
96
+ md: "markdown",
97
+ yml: "yaml",
98
+ rs: "rust",
99
+ htm: "html",
100
+ };
101
+
102
+ const LINK_SAFETY = { enabled: false };
103
+ // Streamdown's default `table` component renders its own copy/download/
104
+ // fullscreen toolbar (the `vo` wrapper). The table component override below
105
+ // replaces that renderer entirely, so the default table controls are turned
106
+ // off here as well for clarity (and so they cannot reappear if a future
107
+ // Streamdown version invokes the default wrapper from another path).
108
+ const STREAMDOWN_CONTROLS = { table: false };
109
+
110
+ const DEFAULT_LABELS = {
111
+ copyCode: "Copy code",
112
+ copied: "Copied",
113
+ wrapLines: "Wrap",
114
+ languageLabel: "text",
115
+ // Markdown table labels — ported from the Chinese locale
116
+ // (packages/ui/src/i18n/locales/zh-CN.ts, markdownTable.*). Hosts override
117
+ // per locale through the `labels` prop on MarkdownResponse.
118
+ copyMarkdown: "复制 Markdown",
119
+ downloadCsv: "下载 CSV",
120
+ openPreview: "预览表格",
121
+ expandScrollMode: "展开表格滚动区域",
122
+ collapseScrollMode: "收回表格滚动区域",
123
+ previewTitle: "表格预览",
124
+ previewDescription: "在更大的可滚动视图中查看表格。",
125
+ copySucceeded: "已复制 Markdown 表格",
126
+ copyFailed: "复制表格失败:{error}",
127
+ downloadFailed: "下载表格失败",
128
+ closePreview: "关闭",
129
+ };
130
+
131
+ function isObject(value) {
132
+ return typeof value === "object" && value !== null;
133
+ }
134
+
135
+ function extractText(node) {
136
+ if (typeof node === "string" || typeof node === "number") return String(node);
137
+ if (Array.isArray(node)) return node.map(extractText).join("");
138
+ if (isObject(node) && isObject(node.props)) return extractText(node.props.children);
139
+ return "";
140
+ }
141
+
142
+ function resolveCodeLanguage(className) {
143
+ if (typeof className !== "string") return "";
144
+ const match = className.match(/(?:^|\s)language-([^\s]+)/);
145
+ return match ? match[1] : "";
146
+ }
147
+
148
+ function resolveLangId(language) {
149
+ const norm = String(language || "").trim().toLowerCase();
150
+ if (!norm) return "";
151
+ if (REGISTERED_LANGS[norm]) return norm;
152
+ const alias = LANG_ALIASES[norm];
153
+ if (alias && REGISTERED_LANGS[alias]) return alias;
154
+ return "";
155
+ }
156
+
157
+ function resolveEffectiveTheme(theme, prefersDark) {
158
+ if (theme === "dark") return "dark";
159
+ if (theme === "light") return "light";
160
+ return prefersDark ? "dark" : "light";
161
+ }
162
+
163
+ function readPrefersDark() {
164
+ if (typeof window !== "object" || typeof window.matchMedia !== "function") return false;
165
+ try {
166
+ return Boolean(window.matchMedia("(prefers-color-scheme: dark)").matches);
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function isExternalHref(href) {
173
+ if (typeof href !== "string") return false;
174
+ return /^https?:\/\//i.test(href);
175
+ }
176
+
177
+ // Bare relative file paths like `src/a.ts` or `README.md` are not known URL
178
+ // schemes, so rehype-harden (in Streamdown's default rehype plugins) replaces
179
+ // them with `[blocked]` before the custom `a` renderer runs. The desktop
180
+ // MessageResponse fixes this with a rehype plugin that normalizes such hrefs
181
+ // to `./…` before harden; this is the minimal equivalent. `./`, `/`, `../`,
182
+ // `#…` and scheme-prefixed hrefs are left alone.
183
+ function shouldRewriteBareFileHref(href) {
184
+ if (typeof href !== "string" || !href) return false;
185
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false;
186
+ if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) return false;
187
+ return /\.[^./\\]+$/.test(href) || href.includes("/") || href.includes("\\");
188
+ }
189
+
190
+ function rewriteBareFileLinkRehypePlugin() {
191
+ return (tree) => {
192
+ const visit = (node) => {
193
+ if (node && node.type === "element") {
194
+ const tag = node.tagName;
195
+ const prop = tag === "a" ? "href" : tag === "img" ? "src" : null;
196
+ if (prop && node.properties && typeof node.properties[prop] === "string") {
197
+ const href = node.properties[prop];
198
+ if (tag === "a" && routeLink(href) === "file") node.properties["data-native-file-href"] = href;
199
+ if (shouldRewriteBareFileHref(href)) node.properties[prop] = "./" + href;
200
+ }
201
+ }
202
+ if (node && Array.isArray(node.children)) node.children.forEach(visit);
203
+ };
204
+ visit(tree);
205
+ };
206
+ }
207
+
208
+ // Classify a link target for the `a` override. External http/https → "external";
209
+ // anything with a scheme (mailto:, tel:, javascript:, data:, …) or an anchor →
210
+ // "muted"; a bare/relative path or filename → "file". Hosts that supply
211
+ // onOpenFileLink get clickable file links; nothing untrusted gets a native <a>.
212
+ function routeLink(href) {
213
+ if (typeof href !== "string" || href === "") return "muted";
214
+ if (isExternalHref(href)) return "external";
215
+ if (href.startsWith("#")) return "muted";
216
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return "muted";
217
+ return "file";
218
+ }
219
+
220
+ // shiki dual-theme tokens carry light color in `htmlStyle.color` and the dark
221
+ // color in `htmlStyle["--shiki-dark"]`; the bg/fg strings are
222
+ // `"#light;--shiki-dark-bg:#dark"`. We remap color→--sdm-c and
223
+ // background-color→--sdm-tbg so CSS can switch with the wrapper theme without
224
+ // fighting inline `color` (which would otherwise win in both themes).
225
+ function tokenStyle(token) {
226
+ const hs = token && token.htmlStyle ? token.htmlStyle : {};
227
+ const style = {};
228
+ for (const key of Object.keys(hs)) {
229
+ const value = hs[key];
230
+ if (key === "color") style["--sdm-c"] = value;
231
+ else if (key === "background-color") style["--sdm-tbg"] = value;
232
+ else style[key] = value;
233
+ }
234
+ return style;
235
+ }
236
+
237
+ function applyDual(style, prefix, combined) {
238
+ if (!combined) return;
239
+ const parts = String(combined).split(";");
240
+ if (parts[0]) style[prefix] = parts[0];
241
+ for (let i = 1; i < parts.length; i++) {
242
+ const idx = parts[i].indexOf(":");
243
+ if (idx > 0) style[parts[i].slice(0, idx).trim()] = parts[i].slice(idx + 1).trim();
244
+ }
245
+ }
246
+
247
+ // Singleton highlighter promise. createHighlighterCore is async even with
248
+ // statically-imported grammars/themes, so we init once and await in the
249
+ // CodeBlock effect. The JS regex engine is synchronous once loaded, so
250
+ // codeToTokens never races the render.
251
+ let highlighterPromise = null;
252
+ function getHighlighter() {
253
+ if (!highlighterPromise) {
254
+ highlighterPromise = createHighlighterCore({
255
+ langs: Object.values(REGISTERED_LANGS),
256
+ themes: [githubLight, githubDark],
257
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
258
+ });
259
+ }
260
+ return highlighterPromise;
261
+ }
262
+
263
+ /**
264
+ * Build the Markdown React components from host-injected React/ReactDOM.
265
+ *
266
+ * const { MarkdownResponse } = createMarkdownComponents(React, ReactDOM);
267
+ * renderText={(text, { streaming }) =>
268
+ * React.createElement(MarkdownResponse, { streaming }, text)}
269
+ *
270
+ * The bundle's only external `require()`s are `react`, `react-dom` and
271
+ * `react/jsx-runtime` (see ../build/build-markdown.cjs). The host must keep
272
+ * those resolved instances identical to the React/ReactDOM passed here.
273
+ *
274
+ * @param {object} React Host React (createElement + hooks).
275
+ * @param {object} [ReactDOM] Host ReactDOM (Streamdown's bundled code
276
+ * requires react-dom for portal-based controls; the host injects it via the
277
+ * external require).
278
+ * @returns {{ MarkdownResponse: object, Markdown: object, cjkPlugin: unknown, registeredLanguages: string[] }}
279
+ */
280
+ export function createMarkdownComponents(React, ReactDOM) {
281
+ if (!isObject(React) || typeof React.createElement !== "function") {
282
+ throw new TypeError("createMarkdownComponents: React.createElement is required");
283
+ }
284
+ const { createElement, useState, useCallback, useEffect, useMemo, useRef, createContext, useContext } = React;
285
+
286
+ // Context carries the per-message values the stable CodeBlock needs but that
287
+ // Streamdown's `pre` wrapper (which only adds `data-block`) cannot forward.
288
+ const CodeBlockContext = createContext(null);
289
+ const useCodeBlockContext = () => useContext(CodeBlockContext);
290
+
291
+ function MarkdownLink(props) {
292
+ const { href, children, className, node: _node, onOpenExternalUrl, onOpenFileLink, "data-native-file-href": originalHref, ...rest } = props;
293
+ const target = typeof originalHref === "string" ? originalHref : typeof href === "string" ? href : "";
294
+ const kind = routeLink(target);
295
+ const handleClick = useCallback(
296
+ (event) => {
297
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
298
+ if (kind === "external" && typeof onOpenExternalUrl === "function") onOpenExternalUrl(target);
299
+ else if (kind === "file" && typeof onOpenFileLink === "function") {
300
+ onOpenFileLink({ href: target, label: extractText(children) });
301
+ }
302
+ },
303
+ [kind, target, onOpenExternalUrl, onOpenFileLink, children],
304
+ );
305
+ if (kind === "external" && typeof onOpenExternalUrl === "function") {
306
+ return createElement(
307
+ "button",
308
+ { type: "button", className: `ks-md-link ${className || ""}`.trim(), title: target, onClick: handleClick },
309
+ children,
310
+ );
311
+ }
312
+ if (kind === "file" && typeof onOpenFileLink === "function") {
313
+ return createElement(
314
+ "button",
315
+ { type: "button", className: `ks-md-link ks-md-file-link ${className || ""}`.trim(), title: target, onClick: handleClick },
316
+ children,
317
+ );
318
+ }
319
+ return createElement(
320
+ "span",
321
+ { className: `ks-md-link-muted ${className || ""}`.trim(), title: target || undefined },
322
+ children,
323
+ );
324
+ }
325
+
326
+ function MarkdownCopyButton(props) {
327
+ const { code, labels, onCopyCode } = props;
328
+ const [copied, setCopied] = useState(false);
329
+ const timerRef = useRef(null);
330
+ useEffect(
331
+ () => () => {
332
+ if (timerRef.current && typeof clearTimeout === "function") clearTimeout(timerRef.current);
333
+ },
334
+ [],
335
+ );
336
+ const handleClick = useCallback(async () => {
337
+ let handled = false;
338
+ if (typeof onCopyCode === "function") {
339
+ try { await onCopyCode(code); handled = true; } catch { handled = false; }
340
+ } else if (typeof navigator === "object" && navigator && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
341
+ try { await navigator.clipboard.writeText(code); handled = true; } catch { handled = false; }
342
+ }
343
+ if (handled) {
344
+ setCopied(true);
345
+ if (timerRef.current) clearTimeout(timerRef.current);
346
+ timerRef.current = setTimeout(() => setCopied(false), 2000);
347
+ }
348
+ }, [code, onCopyCode]);
349
+ return createElement(
350
+ "button",
351
+ {
352
+ type: "button",
353
+ className: "ks-md-copy",
354
+ "aria-label": copied ? labels.copied : labels.copyCode,
355
+ "data-copied": copied ? "true" : "false",
356
+ onClick: handleClick,
357
+ },
358
+ copied ? labels.copied : labels.copyCode,
359
+ );
360
+ }
361
+
362
+ // Stable, independent code block (desktop code-block.tsx / code-viewer.tsx
363
+ // semantics). Raw text while streaming or before the highlighter resolves;
364
+ // shiki tokens when complete. The wrap toggle is local presentation state;
365
+ // copy goes through the host callback or navigator.clipboard.
366
+ function MarkdownCodeBlock(props) {
367
+ const { code, language, streaming, theme, wrapLongLines, labels, onCopyCode } = props;
368
+ const [highlight, setHighlight] = useState(null);
369
+ const tokens = !streaming && highlight?.code === code && highlight?.language === language ? highlight.tokens : null;
370
+ const [wrapped, setWrapped] = useState(Boolean(wrapLongLines));
371
+ useEffect(() => { setWrapped(Boolean(wrapLongLines)); }, [wrapLongLines]);
372
+ useEffect(() => {
373
+ if (streaming) { setHighlight(null); return; }
374
+ const langId = resolveLangId(language);
375
+ if (!langId) { setHighlight(null); return; }
376
+ let cancelled = false;
377
+ getHighlighter().then((h) => {
378
+ if (cancelled) return;
379
+ try {
380
+ const result = h.codeToTokens(code, {
381
+ lang: langId,
382
+ themes: { light: LIGHT_THEME, dark: DARK_THEME },
383
+ });
384
+ if (!cancelled) setHighlight({ code, language, tokens: result });
385
+ } catch {
386
+ if (!cancelled) setHighlight(null);
387
+ }
388
+ }).catch(() => { if (!cancelled) setHighlight(null); });
389
+ return () => { cancelled = true; };
390
+ }, [code, language, streaming]);
391
+
392
+ const toggleWrap = useCallback(() => setWrapped((v) => !v), []);
393
+ const langId = resolveLangId(language);
394
+ const displayLang = language || labels.languageLabel;
395
+
396
+ const bodyChildren = [];
397
+ if (tokens && tokens.tokens) {
398
+ tokens.tokens.forEach((line, lineIdx) => {
399
+ if (lineIdx > 0) bodyChildren.push("\n");
400
+ if (line.length === 0) return;
401
+ line.forEach((tok, tokIdx) => {
402
+ bodyChildren.push(
403
+ createElement("span", { key: `${lineIdx}-${tokIdx}`, className: "ks-md-token", style: tokenStyle(tok) }, tok.content),
404
+ );
405
+ });
406
+ });
407
+ } else {
408
+ bodyChildren.push(code);
409
+ }
410
+ const bodyStyle = {};
411
+ if (tokens && tokens.bg) applyDual(bodyStyle, "--sdm-bg", tokens.bg);
412
+ if (tokens && tokens.fg) applyDual(bodyStyle, "--sdm-fg", tokens.fg);
413
+
414
+ return createElement(
415
+ "div",
416
+ {
417
+ className: "ks-md-code",
418
+ "data-language": displayLang,
419
+ "data-streaming": streaming ? "true" : "false",
420
+ "data-highlighted": tokens ? "true" : "false",
421
+ },
422
+ createElement(
423
+ "div",
424
+ { className: "ks-md-code-header" },
425
+ createElement("span", { className: "ks-md-code-lang" }, displayLang),
426
+ createElement(
427
+ "span",
428
+ { className: "ks-md-code-actions" },
429
+ createElement(
430
+ "button",
431
+ {
432
+ type: "button",
433
+ className: "ks-md-wrap",
434
+ "aria-label": labels.wrapLines,
435
+ "aria-pressed": wrapped ? "true" : "false",
436
+ "data-wrapped": wrapped ? "true" : "false",
437
+ onClick: toggleWrap,
438
+ },
439
+ labels.wrapLines,
440
+ ),
441
+ createElement(MarkdownCopyButton, { code, labels, onCopyCode }),
442
+ ),
443
+ ),
444
+ createElement(
445
+ "pre",
446
+ { className: `ks-md-pre ${wrapped ? "ks-md-pre-wrap" : ""}`.trim(), style: bodyStyle },
447
+ createElement("code", null, bodyChildren),
448
+ ),
449
+ );
450
+ }
451
+
452
+ // `code` override (desktop message.tsx ~1535-1582): inline code renders a
453
+ // bare <code>; block code (detected via the `data-block` flag Streamdown's
454
+ // `pre` renderer adds) routes to the stable CodeBlock using the per-message
455
+ // context. This disables Streamdown's default code renderer (and its async
456
+ // highlighter), which is what avoids React #185.
457
+ function codeComponent(props) {
458
+ const { children, className, node: _node, ...rest } = props;
459
+ const ctx = useCodeBlockContext() || {};
460
+ const isBlock = "data-block" in rest;
461
+ if (!isBlock) {
462
+ return createElement(
463
+ "code",
464
+ { className: `ks-md-inline-code ${className || ""}`.trim() },
465
+ children,
466
+ );
467
+ }
468
+ const codeText = extractText(children).replace(/\n+$/, "");
469
+ const language = resolveCodeLanguage(className);
470
+ return createElement(MarkdownCodeBlock, {
471
+ code: codeText,
472
+ language,
473
+ streaming: ctx.streaming === true,
474
+ wrapLongLines: ctx.wrapLongLines === true,
475
+ theme: ctx.theme,
476
+ labels: ctx.labels || DEFAULT_LABELS,
477
+ onCopyCode: ctx.onCopyCode,
478
+ });
479
+ }
480
+
481
+ function buildComponents(opts) {
482
+ const { onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots } = opts;
483
+ return {
484
+ img: ({ src, alt, title }) => typeof renderImage === "function"
485
+ ? renderImage({ src, alt, title })
486
+ : createElement("span", { role: "img", "aria-label": alt || "Image", title: src }, alt || "Image"),
487
+ code: codeComponent,
488
+ a: (props) =>
489
+ createElement(MarkdownLink, { ...props, onOpenExternalUrl, onOpenFileLink }),
490
+ // Custom table renderer + parts (table-factory.mjs). Replaces Streamdown's
491
+ // default `table` wrapper and its built-in copy/download/fullscreen toolbar.
492
+ table: tableSlots.table,
493
+ thead: tableSlots.thead,
494
+ tbody: tableSlots.tbody,
495
+ tr: tableSlots.tr,
496
+ th: tableSlots.th,
497
+ td: tableSlots.td,
498
+ };
499
+ }
500
+
501
+ function MarkdownResponse(props) {
502
+ const {
503
+ children,
504
+ streaming = false,
505
+ theme = "system",
506
+ scopeKey = "",
507
+ wrapLongLines = false,
508
+ showLineNumbers = false,
509
+ className,
510
+ labels,
511
+ onCopyCode,
512
+ onNotify,
513
+ onOpenExternalUrl,
514
+ onOpenFileLink,
515
+ renderImage,
516
+ } = props || {};
517
+ // Memoize so the table slots and code-block context stay referentially
518
+ // stable across renders when the host keeps `labels` stable.
519
+ const mergedLabels = useMemo(() => ({ ...DEFAULT_LABELS, ...(labels || {}) }), [labels]);
520
+ const markdown = typeof children === "string" ? children : extractText(children);
521
+ const [prefersDark, setPrefersDark] = useState(readPrefersDark);
522
+ useEffect(() => {
523
+ if (typeof window !== "object" || typeof window.matchMedia !== "function") return;
524
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
525
+ const update = () => setPrefersDark(Boolean(query.matches));
526
+ update();
527
+ if (typeof query.addEventListener === "function") {
528
+ query.addEventListener("change", update);
529
+ return () => query.removeEventListener("change", update);
530
+ }
531
+ query.addListener(update);
532
+ return () => query.removeListener(update);
533
+ }, []);
534
+ const effective = resolveEffectiveTheme(theme, prefersDark);
535
+ const tableSlots = useMemo(
536
+ () => createMarkdownTableComponents(React, ReactDOM, { labels: mergedLabels, onNotify, theme: effective }),
537
+ [mergedLabels, onNotify, effective],
538
+ );
539
+ const components = useMemo(
540
+ () => buildComponents({ onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots }),
541
+ [onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots],
542
+ );
543
+ const codeBlockCtx = useMemo(
544
+ () => ({ streaming, wrapLongLines, theme: effective, labels: mergedLabels, onCopyCode }),
545
+ [streaming, wrapLongLines, effective, mergedLabels, onCopyCode],
546
+ );
547
+ const wrapperClass = ["ks-markdown", `ks-theme-${effective}`, className || ""]
548
+ .filter(Boolean)
549
+ .join(" ");
550
+ return createElement(
551
+ CodeBlockContext.Provider,
552
+ { value: codeBlockCtx },
553
+ createElement(
554
+ "div",
555
+ {
556
+ className: wrapperClass,
557
+ "data-streaming": streaming ? "true" : "false",
558
+ "data-theme": effective,
559
+ },
560
+ createElement(
561
+ Streamdown,
562
+ {
563
+ key: `${effective}:${scopeKey}`,
564
+ className: "ks-markdown-body",
565
+ mode: streaming ? "streaming" : "static",
566
+ parseIncompleteMarkdown: streaming,
567
+ components,
568
+ plugins: { cjk: cjkPlugin },
569
+ controls: STREAMDOWN_CONTROLS,
570
+ linkSafety: LINK_SAFETY,
571
+ animated: false,
572
+ isAnimating: false,
573
+ lineNumbers: showLineNumbers,
574
+ rehypePlugins: [defaultRehypePlugins.raw, defaultRehypePlugins.sanitize, rewriteBareFileLinkRehypePlugin, defaultRehypePlugins.harden],
575
+ remarkPlugins: Object.values(defaultRemarkPlugins),
576
+ },
577
+ markdown,
578
+ ),
579
+ ),
580
+ );
581
+ }
582
+
583
+ return {
584
+ MarkdownResponse,
585
+ Markdown: MarkdownResponse,
586
+ cjkPlugin,
587
+ registeredLanguages: Object.keys(REGISTERED_LANGS),
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Dev-time guard: confirms the host's resolved `react` matches the React the
593
+ * caller passed in, so Streamdown's internal `require("react")` and the
594
+ * factory's injected React are the same instance (no second React copy).
595
+ * Throws on mismatch. Pass the host's `require` (or `createRequire`).
596
+ *
597
+ * @param {object} React The React instance passed to createMarkdownComponents.
598
+ * @param {(id: string) => any} [requireFn] Host require; omit to skip.
599
+ */
600
+ export function verifyReactInjection(React, requireFn) {
601
+ if (typeof requireFn !== "function") return;
602
+ let resolved;
603
+ try { resolved = requireFn("react"); } catch { return; }
604
+ if (!resolved) return;
605
+ const sameCreateElement = React.createElement === resolved.createElement;
606
+ const sameHook = React.useState === resolved.useState;
607
+ if (!sameCreateElement || !sameHook) {
608
+ throw new Error(
609
+ "createMarkdownComponents: the injected React does not match the host's require('react'). " +
610
+ "A second React copy would break Streamdown's JSX runtime. Ensure the host resolves a single react instance.",
611
+ );
612
+ }
613
+ }