diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0313d7eec7..9671c029655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1005,6 +1005,9 @@ importers: react-router-dom: specifier: ^7.18.1 version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -1862,11 +1865,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -6596,18 +6599,28 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + hono@4.13.2: resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} engines: {node: '>=16.9.0'} @@ -6966,6 +6979,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -7817,6 +7833,9 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -8323,6 +8342,9 @@ packages: unique-slug@2.0.2: resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -14408,6 +14430,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -14442,12 +14468,21 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 help-me@5.0.0: {} + highlight.js@11.11.2: {} + hono@4.13.2: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): @@ -14770,6 +14805,12 @@ snapshots: loupe@3.2.1: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.2 + lru-cache@10.4.3: {} lru-cache@11.2.7: {} @@ -16082,6 +16123,14 @@ snapshots: dependencies: regex-utilities: 2.3.0 + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -16744,6 +16793,11 @@ snapshots: imurmurhash: 0.1.4 optional: true + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/ui/package.json b/ui/package.json index 1e81e21011c..20d332896c6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -66,6 +66,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.2", "react-router-dom": "^7.18.1", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 3615c501a4a..d52011a3d6f 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -668,3 +668,46 @@ describe("MarkdownBody", () => { }); }); + +describe("code block syntax highlighting", () => { + it("tokenizes a fenced block that declares its language", () => { + const html = renderMarkdown(['```typescript', 'const status = "blocked";', '```'].join("\n")); + + // rehype-highlight marks the code element and wraps each token. + expect(html).toContain("hljs"); + expect(html).toContain("hljs-keyword"); + expect(html).toContain("hljs-string"); + }); + + it("tokenizes an untagged fence, because most agent-authored fences carry no language", () => { + const html = renderMarkdown( + ['```', 'export function run() {', ' return "ok";', '}', '```'].join("\n"), + ); + + expect(html).toContain("hljs-"); + }); + + it("marks added and removed lines in a diff fence", () => { + const html = renderMarkdown( + ['```diff', '- const a = 1;', '+ const a = 2;', '```'].join("\n"), + ); + + expect(html).toContain("hljs-deletion"); + expect(html).toContain("hljs-addition"); + }); + + it("does not throw on a fence whose language is unknown", () => { + expect(() => + renderMarkdown(['```notalanguage', 'some text', '```'].join("\n")), + ).not.toThrow(); + }); + + it("leaves inline code untokenized", () => { + // rehype-highlight only visits `pre > code`. Inline code keeps the + // chip treatment and must not pick up a syntax colour. + const html = renderMarkdown("Set `status` to blocked."); + + expect(html).toContain(">status"); + expect(html).not.toContain("hljs"); + }); +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index a26cafac79e..25514f7191a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; import { cn } from "../lib/utils"; import { Link, useCaseHref } from "@/lib/router"; import { useTheme } from "../context/ThemeContext"; @@ -222,6 +223,37 @@ const scrollableBlockStyle: React.CSSProperties = { overflowX: "auto", }; +/* Syntax highlighting for fenced code blocks. + + `subset` is deliberate. highlight.js auto-detection is unreliable on the + short snippets that appear in task chat, and a wrong guess colours the + whole block wrongly. Restricting detection to the languages agents + actually post makes the guess reliable. `detect` stays on because most + agent-authored fences carry no language tag, and without it those blocks + would never highlight. + + Defined at module scope so the array identity is stable. react-markdown + remounts the rendered tree when its plugin array changes identity, which + discards scroll position and selection — the same hazard the + `remarkPlugins` memo below guards against. */ +const HIGHLIGHT_LANGUAGE_SUBSET = [ + "bash", + "diff", + "json", + "typescript", + "javascript", + "yaml", + "sql", + "python", + "css", + "xml", + "markdown", +]; + +const rehypePlugins: NonNullable = [ + [rehypeHighlight, { detect: true, subset: HIGHLIGHT_LANGUAGE_SUBSET, ignoreMissing: true }], +]; + const codeBlockActionsStyle: React.CSSProperties = { position: "absolute", top: "0.4rem", @@ -942,6 +974,7 @@ function MarkdownBodyImpl({ > diff --git a/ui/src/components/MarkdownCodeBlockStyles.test.ts b/ui/src/components/MarkdownCodeBlockStyles.test.ts index f269e90bf86..bb1236f3d57 100644 --- a/ui/src/components/MarkdownCodeBlockStyles.test.ts +++ b/ui/src/components/MarkdownCodeBlockStyles.test.ts @@ -68,3 +68,45 @@ describe("markdown code block styles", () => { expect(codeBlockActionStyle.backgroundColor).not.toContain("--muted"); }); }); + +describe("code syntax palette", () => { + const aliases = [ + "--code-syntax-keyword", + "--code-syntax-string", + "--code-syntax-number", + "--code-syntax-identifier", + "--code-syntax-punctuation", + "--code-syntax-comment", + "--code-syntax-added", + "--code-syntax-removed", + ]; + + it("defines every syntax role as an alias of an existing token", () => { + for (const alias of aliases) { + const declaration = new RegExp(`${alias}:\\s*var\\(--[a-z0-9_-]+\\)`, "i"); + expect(stylesheet, `${alias} must be defined as var(--existing-token)`).toMatch(declaration); + } + }); + + it("holds the borrowed-token coupling in the alias layer only", () => { + // The syntax rules must reference the aliases, never the borrowed tokens + // directly. That keeps repointing to a real palette a one-block edit. + const syntaxRules = stylesheet.slice(stylesheet.indexOf(".paperclip-markdown :is(pre, code)")); + + expect(syntaxRules).not.toContain("--status-task-icon-"); + expect(syntaxRules).not.toContain("--chip-match-title-fg"); + }); + + it("maps the highlight.js token classes the renderer emits", () => { + for (const cls of [ + ".hljs-keyword", + ".hljs-string", + ".hljs-number", + ".hljs-comment", + ".hljs-addition", + ".hljs-deletion", + ]) { + expect(stylesheet, `${cls} must be mapped`).toContain(cls); + } + }); +}); diff --git a/ui/src/index.css b/ui/src/index.css index 2bc7bc81de0..020c6e52eb2 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1510,6 +1510,73 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { font-size: 1em; } +/* ── Code syntax roles ── + Each alias points at a token that already exists and already carries a + `.dark` override, so one definition covers both modes. A custom property + resolves at its use site, so `.dark` swaps these automatically. + + The mapping is BORROWED. `--status-task-icon-*` and `--chip-match-title-fg` + name task-status and search-chip concerns, not syntax roles. Paperclip has + no syntax palette, and these were the only hues carrying a real per-mode + value: the fixed `--status-task-*` hues go dim on the dark code surface. + The coupling is deliberate and confined to this one block. To mint a real + palette later, repoint these eight aliases — no rule below changes. */ +:root { + --code-syntax-keyword: var(--status-task-icon-in_review); + --code-syntax-string: var(--status-task-icon-done); + --code-syntax-number: var(--chip-match-title-fg); + --code-syntax-identifier: var(--foreground); + --code-syntax-punctuation: var(--muted-foreground); + --code-syntax-comment: var(--muted-foreground); + --code-syntax-added: var(--status-task-icon-done); + --code-syntax-removed: var(--destructive); +} + +/* highlight.js token classes, emitted by rehype-highlight. No highlight.js + stylesheet is imported, so these rules are the only source of colour. */ +.paperclip-markdown :is(pre, code) :is(.hljs-keyword, .hljs-built_in, .hljs-literal, .hljs-type, .hljs-meta, .hljs-selector-tag) { + color: var(--code-syntax-keyword); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-string, .hljs-regexp, .hljs-symbol, .hljs-char, .hljs-quote) { + color: var(--code-syntax-string); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-number, .hljs-title, .hljs-name, .hljs-section, .hljs-link, .hljs-attr, .hljs-attribute, .hljs-selector-id, .hljs-selector-class) { + color: var(--code-syntax-number); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-variable, .hljs-params, .hljs-property, .hljs-subst, .hljs-template-variable) { + color: var(--code-syntax-identifier); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-operator, .hljs-punctuation, .hljs-tag, .hljs-bullet) { + color: var(--code-syntax-punctuation); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-comment, .hljs-doctag) { + color: var(--code-syntax-comment); + font-style: italic; +} + +/* ```diff fences. The row tinting from the design is not implemented, so + the +/- lines are carried by colour alone here. */ +.paperclip-markdown :is(pre, code) .hljs-addition { + color: var(--code-syntax-added); +} + +.paperclip-markdown :is(pre, code) .hljs-deletion { + color: var(--code-syntax-removed); +} + +.paperclip-markdown :is(pre, code) :is(.hljs-emphasis) { + font-style: italic; +} + +.paperclip-markdown :is(pre, code) :is(.hljs-strong) { + font-weight: 600; +} + .paperclip-markdown pre code { font-size: inherit; color: inherit;