import * as React from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { FileText, Download } from 'lucide-react' import { cn } from '@multica/ui/lib/utils' import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style' import { CodeBlock, InlineCode } from './CodeBlock' import { isAllowedFileCardHref, preprocessFileCards } from './file-cards' import { preprocessLinks } from './linkify' import { preprocessIssueIdentifiers } from './issue-identifiers' import { preprocessMentionShortcodes } from './mentions' import { markdownSanitizeSchema, markdownUrlTransform } from './sanitize' import 'katex/dist/katex.min.css' import './markdown.css' /** * Render modes for markdown content: * * - 'terminal': Raw output with minimal formatting, control chars visible * Best for: Debug output, raw logs, when you want to see exactly what's there * * - 'minimal': Clean rendering with syntax highlighting but no extra chrome * Best for: Chat messages, inline content, when you want readability without clutter * * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography * Best for: Documentation, long-form content, when presentation matters */ export type RenderMode = 'terminal' | 'minimal' | 'full' export interface MarkdownProps { children: string /** * Render mode controlling formatting level * @default 'minimal' */ mode?: RenderMode className?: string /** * Message ID for memoization (optional) * When provided, memoizes parsed blocks to avoid re-parsing during streaming */ id?: string /** * Callback when a URL is clicked */ onUrlClick?: (url: string) => void /** * Callback when a file path is clicked */ onFileClick?: (path: string) => void /** * Custom renderer for mention links (e.g. mention://issue/UUID). * When not provided, mentions render as a simple styled span. */ renderMention?: (props: { type: string; id: string }) => React.ReactNode /** * CDN hostname for file card detection (e.g. "multica-static.copilothub.ai"). * When provided, enables file card preprocessing and rendering. */ cdnDomain?: string /** * Optional override for the image renderer. When provided, replaces the * default `` with constrained sizing. The views-package wrapper uses * this to inject the unified `` component so chat messages get * the same hover toolbar / lightbox / preview-modal treatment as comments. */ renderImage?: (props: { src: string; alt: string }) => React.ReactNode /** * Optional override for the file-card renderer. When provided, replaces * the simplified card chrome (filename + download button) with whatever * the caller supplies. Used the same way as `renderImage` to bridge into * the views-package `` component. */ renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode /** * When true, bare issue identifiers (e.g. `MUL-123`, `TES-1`) are rewritten * to `mention://issue/` links so `renderMention` can resolve them * to a navigable issue chip. Off by default — enable only on surfaces whose * `renderMention` knows how to resolve an identifier (see the app wrapper in * packages/views/common/markdown.tsx). Detection is markdown-aware: code, * existing links, URLs, and file/path tokens are skipped. */ autolinkIssueIdentifiers?: boolean } // File path detection regex - matches paths starting with /, ~/, or ./ const FILE_PATH_REGEX = /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i /** * Create custom components based on render mode */ function createComponents( mode: RenderMode, onUrlClick?: (url: string) => void, onFileClick?: (path: string) => void, renderMention?: (props: { type: string; id: string }) => React.ReactNode, renderImage?: (props: { src: string; alt: string }) => React.ReactNode, renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode, ): Partial { const baseComponents: Partial = { // FileCard: intercept
from preprocessFileCards div: ({ node, children, ...props }) => { const dataType = node?.properties?.dataType as string | undefined if (dataType === 'fileCard') { const rawHref = (node?.properties?.dataHref as string) || '' const href = isAllowedFileCardHref(rawHref) ? rawHref : '' const filename = (node?.properties?.dataFilename as string) || '' if (renderFileCard) { return <>{renderFileCard({ href, filename })} } return (

{filename}

{href && ( )}
) } return
{children}
}, // Images: render uploaded images with constrained sizing img: ({ src, alt }) => { if (renderImage) { return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })} } return ( {alt ) }, // Links: Make clickable with callbacks, or render as mention a: ({ href, children }) => { // Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://project/id, mention://all/all if (href?.startsWith('mention://')) { const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/) if (mentionMatch?.[1] && mentionMatch[2]) { const type = mentionMatch[1] const id = mentionMatch[2] if (renderMention) { // Let the custom renderer opt out for types it doesn't handle // by returning null/undefined — we then fall through to the // default styled span so nothing ever disappears silently. const rendered = renderMention({ type, id }) if (rendered) return <>{rendered} } // Fallback: render as a simple styled span return ( {children} ) } return ( {children} ) } if (href?.startsWith('slash://skill/')) { return ( {children} ) } const handleClick = (e: React.MouseEvent): void => { e.preventDefault() if (href) { // Check if it's a file path if (FILE_PATH_REGEX.test(href) && onFileClick) { onFileClick(href) } else if (onUrlClick) { onUrlClick(href) } else { // Default: open in new window window.open(href, '_blank', 'noopener,noreferrer') } } } return ( {children} ) } } // Terminal mode: minimal formatting if (mode === 'terminal') { return { ...baseComponents, // No special code handling - just monospace code: ({ children }) => {children}, pre: ({ children }) => (
          {children}
        
), // Minimal paragraph spacing p: ({ children }) =>

{children}

, // Simple lists ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , // Plain tables table: ({ children }) => {children}
    , th: ({ children }) => {children}, td: ({ children }) => {children} } } // Minimal mode: clean with syntax highlighting if (mode === 'minimal') { return { ...baseComponents, // Inline code code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const isBlock = 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line // Block code - use CodeBlock with full mode if (match || isBlock) { const code = String(children).replace(/\n$/, '') return } // Inline code return {children} }, pre: ({ children }) => <>{children}, // Comfortable paragraph spacing p: ({ children }) =>

    {children}

    , // Styled lists ul: ({ children }) => (
      {children}
    ), ol: ({ children }) =>
      {children}
    , li: ({ children }) =>
  • {children}
  • , // Clean tables table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, th: ({ children }) => ( {children} ), td: ({ children }) => {children}, // Headings - H1/H2 same size, differentiated by weight h1: ({ children }) =>

    {children}

    , h2: ({ children }) => (

    {children}

    ), h3: ({ children }) => (

    {children}

    ), // Blockquotes blockquote: ({ children }) => (
    {children}
    ), // Horizontal rules hr: () =>
    , // Strong/emphasis strong: ({ children }) => {children}, em: ({ children }) => {children} } } // Full mode: rich styling return { ...baseComponents, // Full code blocks with copy button code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const isBlock = 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line if (match || isBlock) { const code = String(children).replace(/\n$/, '') return } return {children} }, pre: ({ children }) => <>{children}, // Rich paragraph spacing p: ({ children }) =>

    {children}

    , // Styled lists ul: ({ children }) => (
      {children}
    ), ol: ({ children }) =>
      {children}
    , li: ({ children }) =>
  • {children}
  • , // Beautiful tables table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, tbody: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, tr: ({ children }) => {children}, // Rich headings h1: ({ children }) =>

    {children}

    , h2: ({ children }) => (

    {children}

    ), h3: ({ children }) =>

    {children}

    , h4: ({ children }) =>

    {children}

    , // Styled blockquotes blockquote: ({ children }) => (
    {children}
    ), // Task lists (GFM) input: ({ type, checked }) => { if (type === 'checkbox') { return ( ) } return }, // Horizontal rules hr: () =>
    , // Strong/emphasis strong: ({ children }) => {children}, em: ({ children }) => {children}, del: ({ children }) => {children} } } /** * Markdown - Customizable markdown renderer with multiple render modes * * Features: * - Three render modes: terminal, minimal, full * - Syntax highlighting via Shiki * - GFM support (tables, task lists, strikethrough) * - Clickable links and file paths * - Memoization for streaming performance * - Pluggable mention rendering via renderMention prop */ export function Markdown({ children, mode = 'minimal', className, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard, cdnDomain, autolinkIssueIdentifiers }: MarkdownProps): React.JSX.Element { const components = React.useMemo( () => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard), [mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard] ) // Preprocess: convert mention shortcodes, bare issue identifiers, raw URLs, // and file cards to renderable content. Issue-identifier autolinking runs // BEFORE linkify/file-card so those passes treat the rewritten spans as // existing markdown links and skip them. const processedContent = React.useMemo( () => { let result = preprocessMentionShortcodes(children) if (autolinkIssueIdentifiers) result = preprocessIssueIdentifiers(result) result = preprocessLinks(result) result = preprocessFileCards(result, cdnDomain ?? '') return result }, [children, cdnDomain, autolinkIssueIdentifiers] ) return (
    {processedContent}
    ) } /** * MemoizedMarkdown - Optimized for streaming scenarios * * Splits content into blocks and memoizes each block separately, * so only new/changed blocks re-render during streaming. */ export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => { // If id is provided, use it for memoization if (prevProps.id && nextProps.id) { return ( prevProps.id === nextProps.id && prevProps.children === nextProps.children && prevProps.mode === nextProps.mode ) } // Otherwise compare content and mode return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode }) MemoizedMarkdown.displayName = 'MemoizedMarkdown'