diff --git a/web/frontend/package.json b/web/frontend/package.json index 40d5cf3d8..7595c46bf 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", + "highlight.js": "^11.11.1", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", @@ -35,6 +36,7 @@ "react-i18next": "^17.0.2", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index e104eaee6..721bd7e75 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: specifier: ^26.0.3 version: 26.0.3(typescript@5.9.3) @@ -62,6 +65,9 @@ importers: react-textarea-autosize: specifier: ^8.5.9 version: 8.5.9(@types/react@19.2.14)(react@19.2.5) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -2433,6 +2439,9 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -2448,6 +2457,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + 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==} @@ -2463,6 +2475,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.12.12: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} @@ -2807,6 +2823,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3371,6 +3390,9 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -3686,6 +3708,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -6253,6 +6278,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -6309,6 +6338,13 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + 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 @@ -6329,6 +6365,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@11.11.1: {} + hono@4.12.12: {} html-parse-stringify@3.0.1: @@ -6574,6 +6612,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7350,6 +7394,14 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + 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 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -7744,6 +7796,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + 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/web/frontend/src/app-providers.tsx b/web/frontend/src/app-providers.tsx new file mode 100644 index 000000000..bfb5dfb38 --- /dev/null +++ b/web/frontend/src/app-providers.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react" + +import { useHighlightTheme } from "./hooks/use-highlight-theme" + +interface AppProvidersProps { + children: ReactNode +} + +export function AppProviders({ children }: AppProvidersProps) { + useHighlightTheme() + + return <>{children} +} diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx index e6f2c75a6..4579926d8 100644 --- a/web/frontend/src/components/agent/skills/detail-sheet.tsx +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -7,6 +7,7 @@ import { import type { ReactNode } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" @@ -171,10 +172,10 @@ export function DetailSheet({ {detailView === "preview" ? ( -
+
{selectedSkillDetail.content} diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 8dcbe15a1..55b7b9bf6 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -2,6 +2,7 @@ import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" @@ -63,7 +64,7 @@ export function AssistantMessage({ >
{content} diff --git a/web/frontend/src/hooks/use-highlight-theme.ts b/web/frontend/src/hooks/use-highlight-theme.ts new file mode 100644 index 000000000..1e4517c3f --- /dev/null +++ b/web/frontend/src/hooks/use-highlight-theme.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react" + +import githubDarkCss from "highlight.js/styles/github-dark.css?inline" +import githubLightCss from "highlight.js/styles/github.css?inline" + +const THEME_STYLE_ID = "hljs-theme-style" +const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme" +const THEME_STYLE_OWNER_VALUE = "true" +const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]` +const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}` + +function getOrCreateThemeStyleElement(): HTMLStyleElement { + const managedStyleElement = document.head.querySelector( + MANAGED_THEME_STYLE_SELECTOR, + ) + if (managedStyleElement) { + return managedStyleElement + } + + const existingStyleElement = + document.querySelector(ID_THEME_STYLE_SELECTOR) + if (existingStyleElement) { + existingStyleElement.setAttribute( + THEME_STYLE_OWNER_ATTR, + THEME_STYLE_OWNER_VALUE, + ) + return existingStyleElement + } + + const conflictingElement = document.getElementById(THEME_STYLE_ID) + const styleElement = document.createElement("style") + if (!conflictingElement) { + styleElement.id = THEME_STYLE_ID + } + + // Leave conflicting non-style nodes untouched and track the injected style explicitly. + styleElement.setAttribute(THEME_STYLE_OWNER_ATTR, THEME_STYLE_OWNER_VALUE) + document.head.appendChild(styleElement) + + return styleElement +} + +export function useHighlightTheme() { + useEffect(() => { + const root = document.documentElement + const styleElement = getOrCreateThemeStyleElement() + + const applyTheme = () => { + const nextThemeCss = root.classList.contains("dark") + ? githubDarkCss + : githubLightCss + styleElement.textContent = nextThemeCss + } + + applyTheme() + + const observer = new MutationObserver(() => { + applyTheme() + }) + + observer.observe(root, { + attributes: true, + attributeFilter: ["class"], + }) + + return () => { + observer.disconnect() + } + }, []) +} diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index 81e72c29f..313daf62d 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router" import { StrictMode } from "react" import ReactDOM from "react-dom/client" +import { AppProviders } from "./app-providers" import "./i18n" import "./index.css" import { routeTree } from "./routeTree.gen" @@ -27,9 +28,11 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( - - - + + + + + , ) }