diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 09cbda4..e001b3c 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; +import type { ComponentProps, CSSProperties, HTMLAttributes, ReactNode } from "react"; import type { BundledLanguage, BundledTheme, @@ -20,8 +20,20 @@ import { import { cn } from "@/lib/utils"; import { useThemeFamily } from "@/lib/theme/context"; import { resolveShikiTheme, resolveShikiThemes, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK } from "@/lib/theme/code-themes"; -import { Check, Copy } from "@phosphor-icons/react"; +import type { Icon } from "@phosphor-icons/react"; import { + Check, + Copy, + CaretDown, + CaretUp, + FileCode, + Terminal, + Code, + File, + Hash, +} from "@phosphor-icons/react"; +import { + createElement, createContext, memo, useCallback, @@ -33,6 +45,24 @@ import { } from "react"; import { createHighlighter } from "shiki"; +// ── Collapse/expand constants ────────────────────────────────────────── +const COLLAPSE_THRESHOLD = 20; +const VISIBLE_LINES = 10; + +// ── Terminal language detection ──────────────────────────────────────── +const TERMINAL_LANGUAGES = new Set(["bash", "sh", "shell", "terminal", "zsh", "console"]); + +// ── Language icon mapping ────────────────────────────────────────────── +function getLanguageIcon(language: string): Icon { + const lower = language.toLowerCase(); + if (TERMINAL_LANGUAGES.has(lower)) return Terminal; + if (["typescript", "tsx", "javascript", "jsx"].includes(lower)) return Code; + if (["json", "yaml", "yml", "toml", "xml"].includes(lower)) return Code; + if (["python", "ruby", "go", "rust", "java", "c", "cpp"].includes(lower)) return Hash; + if (["css", "scss", "html"].includes(lower)) return File; + return FileCode; +} + // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check @@ -104,8 +134,9 @@ const LineSpan = ({ // Types type CodeBlockProps = HTMLAttributes & { code: string; - language: BundledLanguage; + language: BundledLanguage | (string & {}); showLineNumbers?: boolean; + filename?: string; }; interface TokenizedCode { @@ -116,11 +147,13 @@ interface TokenizedCode { interface CodeBlockContextType { code: string; + language: string; } // Context const CodeBlockContext = createContext({ code: "", + language: "text", }); // Highlighter cache keyed by "lang:lightTheme:darkTheme" @@ -411,48 +444,140 @@ export const CodeBlockContent = ({ code, language, showLineNumbers = false, + collapsible = false, + maxCollapsedLines = VISIBLE_LINES, }: { code: string; language: BundledLanguage; showLineNumbers?: boolean; + collapsible?: boolean; + maxCollapsedLines?: number; }) => { const { light: lightTheme, dark: darkTheme } = useShikiThemes(); + const [expanded, setExpanded] = useState(false); + const codeContainerRef = useRef(null); + const [animatingHeight, setAnimatingHeight] = useState(undefined); + + const lines = useMemo(() => code.split("\n"), [code]); + const totalLines = lines.length; + const isCollapsible = collapsible && totalLines > COLLAPSE_THRESHOLD; + + const displayCode = useMemo(() => { + if (!isCollapsible || expanded) return code; + return lines.slice(0, maxCollapsedLines).join("\n"); + }, [code, lines, isCollapsible, expanded, maxCollapsedLines]); // Memoized raw tokens for immediate display - const rawTokens = useMemo(() => createRawTokens(code), [code]); + const rawTokens = useMemo(() => createRawTokens(displayCode), [displayCode]); // Try to get cached result synchronously, otherwise use raw tokens const syncTokenized = useMemo( - () => highlightCode(code, language, undefined, lightTheme, darkTheme) ?? rawTokens, - [code, language, rawTokens, lightTheme, darkTheme] + () => highlightCode(displayCode, language, undefined, lightTheme, darkTheme) ?? rawTokens, + [displayCode, language, rawTokens, lightTheme, darkTheme] ); // Track async highlighting results keyed by code+language+themes to avoid stale state const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null); - const resultKey = `${code}:${language}:${lightTheme}:${darkTheme}`; + const resultKey = `${displayCode}:${language}:${lightTheme}:${darkTheme}`; useEffect(() => { let cancelled = false; // Subscribe to async highlighting result - highlightCode(code, language, (result) => { + highlightCode(displayCode, language, (result) => { if (!cancelled) { - setAsyncResult({ key: `${code}:${language}:${lightTheme}:${darkTheme}`, tokens: result }); + setAsyncResult({ key: `${displayCode}:${language}:${lightTheme}:${darkTheme}`, tokens: result }); } }, lightTheme, darkTheme); return () => { cancelled = true; }; - }, [code, language, lightTheme, darkTheme]); + }, [displayCode, language, lightTheme, darkTheme]); // Only use async result if it matches the current code+language+themes const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized; + const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase()); + + const handleToggleExpand = () => { + const container = codeContainerRef.current; + if (!container) { + setExpanded(!expanded); + return; + } + const currentHeight = container.scrollHeight; + if (!expanded) { + setAnimatingHeight(`${currentHeight}px`); + setExpanded(true); + requestAnimationFrame(() => { + const fullHeight = container.scrollHeight; + setAnimatingHeight(`${fullHeight}px`); + setTimeout(() => setAnimatingHeight(undefined), 300); + }); + } else { + setAnimatingHeight(`${currentHeight}px`); + requestAnimationFrame(() => { + const collapsedH = maxCollapsedLines * 1.5 + 1.5; + setAnimatingHeight(`${collapsedH}rem`); + setTimeout(() => { + setExpanded(false); + setAnimatingHeight(undefined); + }, 300); + }); + } + }; + return ( -
- -
+ <> +
+
+ +
+ + {/* Gradient overlay for collapsed state */} + {isCollapsible && !expanded && ( +
+ )} +
+ + {/* Expand/Collapse button */} + {isCollapsible && ( + + )} + ); }; @@ -460,26 +585,164 @@ export const CodeBlock = ({ code, language, showLineNumbers = false, + filename, className, children, ...props }: CodeBlockProps) => { - const contextValue = useMemo(() => ({ code }), [code]); + const contextValue = useMemo(() => ({ code, language }), [code, language]); + const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase()); + + // When children are provided, use the composable API (caller controls header). + // Otherwise, render a default header with language icon, copy, copy-as-markdown. + const hasCustomChildren = children != null; return ( - - {children} + + {hasCustomChildren ? ( + children + ) : ( + + )} ); }; +/** Default header rendered when CodeBlock has no children (non-composable usage). */ +const CodeBlockDefaultHeader = ({ + language, + filename, + isTerminal, +}: { + language: string; + filename?: string; + isTerminal: boolean; +}) => { + const { code: contextCode, language: contextLanguage } = useContext(CodeBlockContext); + const [copied, setCopied] = useState(false); + const [copiedMarkdown, setCopiedMarkdown] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(contextCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard not available + } + }; + + const handleCopyMarkdown = async () => { + try { + const markdown = `\`\`\`${contextLanguage}\n${contextCode}\n\`\`\``; + await navigator.clipboard.writeText(markdown); + setCopiedMarkdown(true); + setTimeout(() => setCopiedMarkdown(false), 2000); + } catch { + // clipboard not available + } + }; + + const langIcon = getLanguageIcon(language); + + return ( +
+
+ {createElement(langIcon, { size: 14, className: cn( + "shrink-0", + isTerminal ? "text-green-400" : "text-muted-foreground", + ) })} + {filename && ( + {filename} + )} + {filename && |} + {language.toUpperCase()} +
+
+ + +
+
+ ); +}; + export type CodeBlockCopyButtonProps = ComponentProps & { onCopy?: () => void; onError?: (error: Error) => void; @@ -591,3 +854,12 @@ export type CodeBlockLanguageSelectorItemProps = ComponentProps< export const CodeBlockLanguageSelectorItem = ( props: CodeBlockLanguageSelectorItemProps ) => ; + +// ── InlineCode ───────────────────────────────────────────────────────── +export function InlineCode({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/chat/CodeBlock.tsx b/src/components/chat/CodeBlock.tsx deleted file mode 100644 index 9ecfa45..0000000 --- a/src/components/chat/CodeBlock.tsx +++ /dev/null @@ -1,293 +0,0 @@ -'use client'; - -import { useState, useMemo, useRef, createElement } from 'react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { useThemeFamily } from '@/lib/theme/context'; -import { resolveCodeTheme, resolvePrismStyle } from '@/lib/theme/code-themes'; -import { useTheme } from 'next-themes'; -import type { Icon } from "@phosphor-icons/react"; -import { - Copy, - Check, - CaretDown, - CaretUp, - FileCode, - Terminal, - Code, - File, - Hash, -} from "@phosphor-icons/react"; -import { cn } from '@/lib/utils'; - -const COLLAPSE_THRESHOLD = 20; -const VISIBLE_LINES = 10; - -const TERMINAL_LANGUAGES = new Set(['bash', 'sh', 'shell', 'terminal', 'zsh', 'console']); - -function getLanguageIcon(language: string): Icon { - const lower = language.toLowerCase(); - if (TERMINAL_LANGUAGES.has(lower)) return Terminal; - if (['typescript', 'tsx', 'javascript', 'jsx'].includes(lower)) return Code; - if (['json', 'yaml', 'yml', 'toml', 'xml'].includes(lower)) return Code; - if (['python', 'ruby', 'go', 'rust', 'java', 'c', 'cpp'].includes(lower)) return Hash; - if (['css', 'scss', 'html'].includes(lower)) return File; - return FileCode; -} - -interface CodeBlockProps { - code: string; - language?: string; - filename?: string; - showLineNumbers?: boolean; - maxCollapsedLines?: number; -} - -/** Resolve the Prism syntax highlighting style for CodeBlock. */ -function useCodeBlockTheme(isTerminal: boolean, isDark: boolean) { - const { family, families } = useThemeFamily(); - if (isTerminal) return vscDarkPlus; - const codeTheme = resolveCodeTheme(families, family); - return resolvePrismStyle(codeTheme, isDark); -} - -export function CodeBlock({ - code, - language = 'text', - filename, - showLineNumbers = true, - maxCollapsedLines = VISIBLE_LINES, -}: CodeBlockProps) { - const [copied, setCopied] = useState(false); - const [copiedMarkdown, setCopiedMarkdown] = useState(false); - const [expanded, setExpanded] = useState(false); - const codeContainerRef = useRef(null); - const [animatingHeight, setAnimatingHeight] = useState(undefined); - const { resolvedTheme } = useTheme(); - const isDark = resolvedTheme === 'dark'; - - const lines = useMemo(() => code.split('\n'), [code]); - const totalLines = lines.length; - const isCollapsible = totalLines > COLLAPSE_THRESHOLD; - const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase()); - - const displayCode = useMemo(() => { - if (!isCollapsible || expanded) return code; - return lines.slice(0, maxCollapsedLines).join('\n'); - }, [code, lines, isCollapsible, expanded, maxCollapsedLines]); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // clipboard not available - } - }; - - const handleCopyMarkdown = async () => { - try { - const markdown = `\`\`\`${language}\n${code}\n\`\`\``; - await navigator.clipboard.writeText(markdown); - setCopiedMarkdown(true); - setTimeout(() => setCopiedMarkdown(false), 2000); - } catch { - // clipboard not available - } - }; - - const handleToggleExpand = () => { - const container = codeContainerRef.current; - if (!container) { - setExpanded(!expanded); - return; - } - const currentHeight = container.scrollHeight; - if (!expanded) { - setAnimatingHeight(`${currentHeight}px`); - setExpanded(true); - requestAnimationFrame(() => { - const fullHeight = container.scrollHeight; - setAnimatingHeight(`${fullHeight}px`); - setTimeout(() => setAnimatingHeight(undefined), 300); - }); - } else { - setAnimatingHeight(`${currentHeight}px`); - requestAnimationFrame(() => { - const collapsedH = maxCollapsedLines * 1.5 + 1.5; - setAnimatingHeight(`${collapsedH}rem`); - setTimeout(() => { - setExpanded(false); - setAnimatingHeight(undefined); - }, 300); - }); - } - }; - - const languageIcon = getLanguageIcon(language); - - const theme = useCodeBlockTheme(isTerminal, isDark); - - return ( -
- {/* Header bar */} -
-
- {createElement(languageIcon, { size: 14, className: cn( - "shrink-0", - isTerminal ? "text-green-400" : "text-muted-foreground", - ) })} - {filename && ( - {filename} - )} - {filename && |} - {language.toUpperCase()} -
-
- - -
-
- - {/* Code area */} -
- - {expanded ? code : displayCode} - - - {/* Gradient overlay for collapsed state */} - {isCollapsible && !expanded && ( -
- )} -
- - {/* Expand/Collapse button */} - {isCollapsible && ( - - )} -
- ); -} - -// Inline code component for reuse -export function InlineCode({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/src/components/chat/ToolCallBlock.tsx b/src/components/chat/ToolCallBlock.tsx index dba355a..d6a474c 100644 --- a/src/components/chat/ToolCallBlock.tsx +++ b/src/components/chat/ToolCallBlock.tsx @@ -15,7 +15,7 @@ import { XCircle, } from "@phosphor-icons/react"; import { cn } from '@/lib/utils'; -import { CodeBlock } from './CodeBlock'; +import { CodeBlock } from '@/components/ai-elements/code-block'; type ToolStatus = 'running' | 'success' | 'error';