From 1fbb4e4e6a9e14c668f7df73012d596c9b29ecd0 Mon Sep 17 00:00:00 2001 From: zhengzuo0-ai Date: Tue, 19 May 2026 00:53:24 +0800 Subject: [PATCH] ui: highlight WebChat code blocks (#83569) Summary: - The PR adds highlight.js-backed WebChat code-block highlighting, scoped token CSS, regression tests, a type shim, and a direct UI dependency. - Reproducibility: not applicable. as a bug reproduction; this is a feature addition. The feature gap is source-evident because current main renders code blocks as escaped plaintext without hljs token markup. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head 7bb95c47edd4cd27037da33dda85de5ae72948f4. - Required merge gates passed before the squash merge. Prepared head SHA: 7bb95c47edd4cd27037da33dda85de5ae72948f4 Review: https://github.com/openclaw/openclaw/pull/83569#issuecomment-4476990135 Co-authored-by: zhengzuo0-ai Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- pnpm-lock.yaml | 3 + ui/package.json | 1 + ui/src/styles/components.css | 153 ++++++++++++++++++++++++ ui/src/styles/components.test.ts | 12 ++ ui/src/types/highlight-js-subpaths.d.ts | 9 ++ ui/src/ui/markdown.test.ts | 91 ++++++++++++-- ui/src/ui/markdown.ts | 110 ++++++++++++++++- 7 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 ui/src/types/highlight-js-subpaths.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2a565f72f..d807723a1d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1736,6 +1736,9 @@ importers: dompurify: specifier: 3.4.3 version: 3.4.3 + highlight.js: + specifier: 10.7.3 + version: 10.7.3 json5: specifier: 2.2.3 version: 2.2.3 diff --git a/ui/package.json b/ui/package.json index fbc3054c10d..3fbe4e19a8f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "@create-markdown/preview": "2.0.3", "@noble/ed25519": "3.1.0", "dompurify": "3.4.3", + "highlight.js": "10.7.3", "json5": "2.2.3", "lit": "3.3.3", "markdown-it": "14.1.1", diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 12bc0a4cc6e..6714a9cfc9e 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -2004,6 +2004,159 @@ background: var(--bg); } +:is(.code-block .hljs, .code-block-wrapper pre code.hljs) { + color: var(--text); +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-comment, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-quote { + color: var(--muted); + font-style: italic; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-tag, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst { + color: #ff8a80; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-template-variable, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-tag .hljs-attr { + color: #79c0ff; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag { + color: #a5d6ff; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-id, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-function .hljs-title { + color: #d2a8ff; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-class .hljs-title, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-builtin-name { + color: #ffa657; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-class, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-attr, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-pseudo { + color: #7ee787; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-symbol, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-bullet, +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-link { + color: var(--accent-2); +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta { + color: #c9d1d9; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-deletion { + color: #ffa198; + background: rgba(248, 81, 73, 0.12); +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-addition { + color: #7ee787; + background: rgba(46, 160, 67, 0.12); +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-emphasis { + font-style: italic; +} + +:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-strong { + font-weight: 600; +} + +:root[data-theme-mode="light"] :is(.code-block .hljs, .code-block-wrapper pre code.hljs) { + color: var(--text); +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-selector-tag, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst { + color: #cf222e; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-template-variable, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-tag + .hljs-attr { + color: #0550ae; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag { + color: #0a3069; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-selector-id, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-function + .hljs-title { + color: #8250df; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-class + .hljs-title, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-builtin-name { + color: #953800; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute, +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-selector-class, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-selector-attr, +:root[data-theme-mode="light"] + :is(.code-block, .code-block-wrapper pre code.hljs) + .hljs-selector-pseudo { + color: #116329; +} + +:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta { + color: #57606a; +} + .markdown-plain-text-fallback { display: block; white-space: pre-wrap; diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index 49df92d12a3..4037480d132 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -5,6 +5,18 @@ function readComponentsCss(): string { return readStyleSheet("ui/src/styles/components.css"); } +describe("code block highlight styles", () => { + it("targets the markdown renderer's generated code block wrapper", () => { + const css = readComponentsCss(); + + expect(css).toContain(":is(.code-block .hljs, .code-block-wrapper pre code.hljs)"); + expect(css).toContain(":is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword"); + expect(css).toContain( + ':root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string', + ); + }); +}); + describe("agent fallback chip styles", () => { it("styles the chip remove control inside the agent model input", () => { const css = readComponentsCss(); diff --git a/ui/src/types/highlight-js-subpaths.d.ts b/ui/src/types/highlight-js-subpaths.d.ts new file mode 100644 index 00000000000..535d78b37fd --- /dev/null +++ b/ui/src/types/highlight-js-subpaths.d.ts @@ -0,0 +1,9 @@ +declare module "highlight.js/lib/core.js" { + import hljs = require("highlight.js"); + + export default hljs; +} + +declare module "highlight.js/lib/languages/*.js" { + export default function language(hljs?: HLJSApi): LanguageDetail; +} diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 0549558f6cf..c2645ed978d 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -332,9 +332,14 @@ describe("toSanitizedMarkdownHtml", () => { describe("code blocks", () => { it("renders fenced code blocks", () => { const html = toSanitizedMarkdownHtml("```ts\nconsole.log(1)\n```"); - expect(html).toBe( - '
ts
console.log(1)\n
', - ); + const fragment = htmlFragment(html); + const code = fragment.querySelector("pre code"); + const copy = fragment.querySelector(".code-block-copy"); + + expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("ts"); + expect(copy?.dataset.code).toBe("console.log(1)"); + expect(code?.classList.contains("language-ts")).toBe(true); + expect(code?.textContent).toBe("console.log(1)\n"); }); it("renders indented code blocks", () => { @@ -352,6 +357,52 @@ describe("toSanitizedMarkdownHtml", () => { ); }); + it("highlights fenced code blocks while preserving copy text", () => { + const source = 'const answer = "yes";\nconsole.log(answer);\n'; + const html = toSanitizedMarkdownHtml(`\`\`\`js\n${source}\`\`\``); + const fragment = htmlFragment(html); + const code = fragment.querySelector("pre code"); + const copy = fragment.querySelector(".code-block-copy"); + + expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("js"); + expect(copy?.dataset.code).toBe(source.trimEnd()); + expect(code?.textContent).toBe(source); + expect(code?.querySelector(".hljs-keyword")?.textContent).toBe("const"); + expect(code?.querySelector(".hljs-string")?.textContent).toBe('"yes"'); + }); + + it("highlights collapsed JSON code blocks", () => { + const html = toSanitizedMarkdownHtml('```json\n{"ok": true}\n```'); + const fragment = htmlFragment(html); + const details = fragment.querySelector("details.json-collapse"); + const code = details?.querySelector("pre code"); + + expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines"); + expect(code?.textContent).toBe('{"ok": true}\n'); + expect(code?.innerHTML).toContain("hljs-"); + }); + + it("auto-highlights unlabeled code blocks only when detection is confident", () => { + const html = toSanitizedMarkdownHtml("```\n#include \nstd::vector nums;\n```"); + const fragment = htmlFragment(html); + const code = fragment.querySelector("pre code"); + + expect(code?.classList.contains("hljs")).toBe(true); + expect(code?.textContent).toBe("#include \nstd::vector nums;\n"); + expect(code?.innerHTML).toContain("hljs-meta"); + expect(code?.innerHTML).toContain("hljs-keyword"); + }); + + it("keeps highlighted HTML code escaped", () => { + const html = toSanitizedMarkdownHtml("```html\n\n```"); + const fragment = htmlFragment(html); + const code = fragment.querySelector("pre code"); + + expect(code?.querySelector("script")).toBeNull(); + expect(code?.textContent).toBe("\n"); + expect(code?.innerHTML).not.toContain("