diff --git a/bun.lock b/bun.lock index 8c139729..19912741 100644 --- a/bun.lock +++ b/bun.lock @@ -76,14 +76,17 @@ "@assistant-ui/react-markdown": "^0.11.8", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", + "@shikijs/langs": "^3.20.0", + "@shikijs/themes": "^3.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", + "hast-util-to-jsx-runtime": "^2.3.6", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-shiki": "^0.9.1", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", + "shiki": "^3.20.0", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.5.5", }, @@ -1722,8 +1725,6 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-shiki": ["react-shiki@0.9.1", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ln1PnISi7WaSlheSBRdxVruVbU1zMUkCmxe+vmbIvZSsHdfvOF5NBOgf1h4cCr6OjdR0dLAxmPKcx3tobdyxVA=="], - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], diff --git a/web/package.json b/web/package.json index 31b45042..60b888e6 100644 --- a/web/package.json +++ b/web/package.json @@ -14,14 +14,17 @@ "@assistant-ui/react-markdown": "^0.11.8", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", + "@shikijs/langs": "^3.20.0", + "@shikijs/themes": "^3.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", + "hast-util-to-jsx-runtime": "^2.3.6", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-shiki": "^0.9.1", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", + "shiki": "^3.20.0", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.5.5" }, diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index 063123af..90343a75 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -1,21 +1,6 @@ -import type { Themes } from 'react-shiki/web' -import { useMemo, useState } from 'react' -import { useShikiHighlighter } from 'react-shiki/web' +import { useState } from 'react' import { usePlatform } from '@/hooks/usePlatform' - -const SHIKI_THEMES: Themes = { - light: 'github-light', - dark: 'github-dark', -} - -function normalizeLanguage(language?: string): string { - const raw = language?.trim() - if (!raw) return 'text' - const cleaned = raw.startsWith('language-') ? raw.slice('language-'.length) : raw - const canonical = cleaned.toLowerCase() - if (canonical === 'text' || canonical === 'plaintext' || canonical === 'txt') return 'text' - return cleaned -} +import { useShikiHighlighter } from '@/lib/shiki' function safeCopyToClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { @@ -70,14 +55,10 @@ export function CodeBlock(props: { }) { const { haptic } = usePlatform() const showCopyButton = props.showCopyButton ?? true - const normalizedLanguage = useMemo(() => normalizeLanguage(props.language), [props.language]) const [copied, setCopied] = useState(false) - const highlighted = useShikiHighlighter(props.code, normalizedLanguage, SHIKI_THEMES, { - delay: 75, - structure: 'inline', - }) + const highlighted = useShikiHighlighter(props.code, props.language) const handleCopy = async () => { try { @@ -103,10 +84,8 @@ export function CodeBlock(props: { ) : null} -
-                
-                    {highlighted ?? props.code}
-                
+            
+                {highlighted ?? props.code}
             
) diff --git a/web/src/components/assistant-ui/shiki-highlighter.tsx b/web/src/components/assistant-ui/shiki-highlighter.tsx index 82f9b4dc..2ca5ec0e 100644 --- a/web/src/components/assistant-ui/shiki-highlighter.tsx +++ b/web/src/components/assistant-ui/shiki-highlighter.tsx @@ -1,24 +1,14 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' -import ShikiHighlighter from 'react-shiki/web' - -const SHIKI_THEMES = { - light: 'github-light', - dark: 'github-dark', -} as const +import { useShikiHighlighter } from '@/lib/shiki' export function SyntaxHighlighter(props: SyntaxHighlighterProps) { + const highlighted = useShikiHighlighter(props.code, props.language) + return (
- - {props.code} - +
+                {highlighted ?? props.code}
+            
) } diff --git a/web/src/index.css b/web/src/index.css index ecd308b1..d6b42111 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -111,6 +111,15 @@ body { background-color: transparent !important; } +/* Shiki dual theme colors */ +.shiki, +.shiki span { + color: var(--shiki-light) !important; + font-style: var(--shiki-light-font-style) !important; + font-weight: var(--shiki-light-font-weight) !important; + text-decoration: var(--shiki-light-text-decoration) !important; +} + html[data-theme="dark"] .shiki, html[data-theme="dark"] .shiki span { color: var(--shiki-dark) !important; diff --git a/web/src/lib/shiki.ts b/web/src/lib/shiki.ts new file mode 100644 index 00000000..7ae51583 --- /dev/null +++ b/web/src/lib/shiki.ts @@ -0,0 +1,164 @@ +import { createHighlighterCore } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import type { HighlighterCore } from 'shiki/core' +import { useState, useEffect, useMemo, type ReactNode } from 'react' +import { toJsxRuntime } from 'hast-util-to-jsx-runtime' +import { jsx, jsxs, Fragment } from 'react/jsx-runtime' + +// Only 2 themes +const THEMES = [ + import('@shikijs/themes/github-light'), + import('@shikijs/themes/github-dark'), +] + +// 30 common languages for LLM code output +const LANGS = [ + // Shell + import('@shikijs/langs/shellscript'), + import('@shikijs/langs/powershell'), + // Data formats + import('@shikijs/langs/json'), + import('@shikijs/langs/yaml'), + import('@shikijs/langs/toml'), + import('@shikijs/langs/xml'), + import('@shikijs/langs/ini'), + // Markup + import('@shikijs/langs/markdown'), + import('@shikijs/langs/html'), + import('@shikijs/langs/css'), + import('@shikijs/langs/scss'), + // JavaScript ecosystem + import('@shikijs/langs/javascript'), + import('@shikijs/langs/typescript'), + import('@shikijs/langs/jsx'), + import('@shikijs/langs/tsx'), + // Query languages + import('@shikijs/langs/sql'), + import('@shikijs/langs/graphql'), + // Systems languages + import('@shikijs/langs/c'), + import('@shikijs/langs/rust'), + import('@shikijs/langs/go'), + // JVM + import('@shikijs/langs/java'), + import('@shikijs/langs/kotlin'), + // Scripting + import('@shikijs/langs/python'), + import('@shikijs/langs/php'), + // Apple + import('@shikijs/langs/swift'), + // .NET + import('@shikijs/langs/csharp'), + // DevOps + import('@shikijs/langs/dockerfile'), + import('@shikijs/langs/make'), + // Misc + import('@shikijs/langs/diff'), +] + +export const SHIKI_THEMES = { + light: 'github-light', + dark: 'github-dark', +} as const + +// Alias common code fence language names to canonical names +export const langAlias: Record = { + sh: 'shellscript', + bash: 'shellscript', + zsh: 'shellscript', + shell: 'shellscript', + ps1: 'powershell', + js: 'javascript', + ts: 'typescript', + mjs: 'javascript', + cjs: 'javascript', + mts: 'typescript', + cts: 'typescript', + yml: 'yaml', + md: 'markdown', + htm: 'html', + pgsql: 'sql', + mysql: 'sql', + postgres: 'sql', + gql: 'graphql', + py: 'python', + rs: 'rust', + kt: 'kotlin', + cs: 'csharp', + makefile: 'make', +} + +// Singleton highlighter instance +let highlighterPromise: Promise | null = null + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighterCore({ + themes: THEMES, + langs: LANGS, + engine: createJavaScriptRegexEngine({ forgiving: true }), + }) + } + return highlighterPromise +} + +function resolveLanguage(lang: string | undefined): string { + if (!lang) return 'text' + const cleaned = lang.startsWith('language-') ? lang.slice('language-'.length) : lang + const lower = cleaned.toLowerCase().trim() + if (lower === 'text' || lower === 'plaintext' || lower === 'txt') return 'text' + return langAlias[lower] ?? lower +} + +/** + * Custom hook for syntax highlighting with our minimal Shiki bundle + */ +export function useShikiHighlighter( + code: string, + language: string | undefined +): ReactNode | null { + const [highlighted, setHighlighted] = useState(null) + const lang = useMemo(() => resolveLanguage(language), [language]) + + useEffect(() => { + let cancelled = false + + async function highlight() { + const highlighter = await getHighlighter() + if (cancelled) return + + const loadedLangs = highlighter.getLoadedLanguages() + + // Skip highlighting for unsupported languages (graceful fallback to plain text) + if (lang === 'text' || !loadedLangs.includes(lang)) { + setHighlighted(null) + return + } + + const hast = highlighter.codeToHast(code, { + lang, + themes: SHIKI_THEMES, + defaultColor: false, + structure: 'inline', + }) + + if (cancelled) return + + const rendered = toJsxRuntime(hast, { + jsx, + jsxs, + Fragment, + }) + setHighlighted(rendered as ReactNode) + } + + // Debounce highlighting + const timer = setTimeout(highlight, 50) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [code, lang]) + + return highlighted +}