import React from 'react'; import morphdom from 'morphdom'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import type { Theme } from '@/types/theme'; import type { ToolPopupContent } from './message/types'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; import { attachMarkdownInteractions, applyMarkdownCodeBlockWrapState, decorateMarkdown, scheduleMarkdownCodeLineNumberSync, syncMarkdownCodeLineNumbers, type DecorateContext, type DecorateLabels, type MermaidControlOptions, type MermaidRender, } from './markdown/decorate'; import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer'; import { BLOCK_PATH_TOKEN_RE, isAbsoluteReferencePath, normalizeReferencePath, parseFileReference, type ParsedFileReference, } from './fileReferenceParser'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); const fallbackLight = getDefaultTheme(false); const fallbackDark = getDefaultTheme(true); return themeSystem?.currentTheme ?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches ? fallbackDark : fallbackLight); }; const useExternalLinkInteractions = ({ containerRef, enabled, }: { containerRef: React.RefObject; enabled?: boolean; }) => { React.useEffect(() => { if (enabled === false) { return; } const container = containerRef.current; if (!container) { return; } const handleClick = (event: MouseEvent) => { if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { return; } const target = event.target; if (!(target instanceof Element)) { return; } const anchor = target.closest('a[href]'); if (!(anchor instanceof HTMLAnchorElement)) { return; } if (anchor.getAttribute('data-openchamber-file-link') === 'true') { return; } const href = anchor.getAttribute('href') ?? ''; if (!isExternalHttpUrl(href)) { return; } event.preventDefault(); event.stopPropagation(); void openExternalUrl(href); }; container.addEventListener('click', handleClick); return () => { container.removeEventListener('click', handleClick); }; }, [containerRef, enabled]); }; const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = { download: true, copy: true, showPanZoomControls: true, }; const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true; const stripLeadingFrontmatter = (markdown: string): string => { const frontmatterMatch = markdown.match( /^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/, ); if (!frontmatterMatch) { return markdown; } return markdown.slice(frontmatterMatch[0].length); }; export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning'; interface MarkdownRendererProps { content: string; part?: Part; messageId: string; isAnimated?: boolean; skipFadeIn?: boolean; className?: string; isStreaming?: boolean; disableStreamAnimation?: boolean; variant?: MarkdownVariant; onShowPopup?: (content: ToolPopupContent) => void; enableFileReferences?: boolean; } const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned'; // Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style // output. The regex is defined in `./fileReferenceParser`; the inline-code // pipeline reads full text content rather than using this regex. const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CACHE_MAX = 1000; const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200; const FILE_REFERENCE_LINK_LIMIT = 80; const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40; const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160; const FILE_REFERENCE_STAT_CACHE = new Map>(); let activeFileReferenceStatCount = 0; const pendingFileReferenceStats: Array<() => void> = []; const getFileReferenceStatCacheMax = (): number => ( isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX ); const getFileReferenceLinkLimit = (): number => ( isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT ); const KNOWN_FILE_BASENAMES = new Set([ 'dockerfile', 'makefile', 'readme', 'license', '.env', '.gitignore', '.npmrc', ]); const normalizePath = (value: string): string => { return normalizeReferencePath(value); }; const isAbsolutePath = (value: string): boolean => { return isAbsoluteReferencePath(value); }; const toAbsolutePath = (basePath: string, targetPath: string): string => { return toAbsoluteFilePath(basePath, targetPath); }; const hasFileExtension = (path: string): boolean => { const base = path.split('/').filter(Boolean).pop() ?? ''; if (!base || base.endsWith('.')) { return false; } return /\.[A-Za-z0-9_-]{1,16}$/.test(base); }; const isLikelyFilePathValue = (path: string): boolean => { if (!path || path.startsWith('--') || path.includes('://')) { return false; } if (/[<>]/.test(path) || /\s{2,}/.test(path)) { return false; } const normalized = normalizePath(path); const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized; if (!baseName || baseName === '.' || baseName === '..') { return false; } const base = baseName.toLowerCase(); if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) { return true; } return hasFileExtension(normalized); }; const isLikelyFilePath = (value: string): boolean => { const parsed = parseFileReference(value); if (!parsed) { return false; } return isLikelyFilePathValue(parsed.path); }; const findTextPosition = (textNodes: Text[], targetOffset: number): { node: Text; offset: number } | null => { let currentOffset = 0; for (const node of textNodes) { const nextOffset = currentOffset + node.data.length; if (targetOffset <= nextOffset) { return { node, offset: Math.max(0, targetOffset - currentOffset) }; } currentOffset = nextOffset; } const lastNode = textNodes.at(-1); return lastNode ? { node: lastNode, offset: lastNode.data.length } : null; }; const unwrapBlockCodePathTokens = (container: HTMLElement): void => { const tokenSpans = container.querySelectorAll(BLOCK_PATH_TOKEN_SELECTOR); for (const span of Array.from(tokenSpans)) { span.replaceWith(container.ownerDocument.createTextNode(span.textContent ?? '')); } const scannedBlocks = container.querySelectorAll(`code[${CODE_BLOCK_PATH_SCANNED_ATTR}]`); for (const codeBlock of Array.from(scannedBlocks)) { codeBlock.removeAttribute(CODE_BLOCK_PATH_SCANNED_ATTR); codeBlock.normalize(); } }; const extractPathCandidateFromElement = (element: HTMLElement): string => { if (element.tagName.toLowerCase() === 'a') { const href = element.getAttribute('href')?.trim(); if (href && isLikelyFilePath(href)) { return href; } } return (element.textContent || '').trim(); }; // Walks text nodes inside `
` subtrees and wraps any substring that
// looks like a `path[:line[:col]]` reference in a span carrying
// `data-openchamber-block-path-token`. `annotateFileLinks` then promotes those
// spans into clickable file links via the same existing pipeline used for
// inline code (parseFileReference → fileReferenceExists → openFileReference).
//
// Idempotent: each `` node is marked with
// `data-openchamber-block-paths-scanned` once processed so the walk is not
// repeated on the same element. When the renderer replaces the `` subtree
// (e.g. on content change during streaming), the new element lacks the marker and
// will be rescanned on the next mutation-observer callback.
const wrapBlockCodePathTokens = (container: HTMLElement): void => {
  const codeBlocks = container.querySelectorAll('pre code');
  if (codeBlocks.length === 0) {
    return;
  }

  const doc = container.ownerDocument;
  if (!doc) {
    return;
  }

  for (const codeBlock of Array.from(codeBlocks)) {
    if (codeBlock.getAttribute(CODE_BLOCK_PATH_SCANNED_ATTR) === 'true') {
      continue;
    }

    // Skip absurdly large code blocks to keep DOM work bounded.
    if ((codeBlock.textContent ?? '').length > MAX_BLOCK_CODE_SCAN_LENGTH) {
      codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
      continue;
    }

    const walker = doc.createTreeWalker(codeBlock, NodeFilter.SHOW_TEXT);
    const textNodes: Text[] = [];
    let currentNode = walker.nextNode();
    while (currentNode) {
      textNodes.push(currentNode as Text);
      currentNode = walker.nextNode();
    }

    const fullText = codeBlock.textContent ?? '';
    if (!fullText.includes('.')) {
      codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
      continue;
    }

    BLOCK_PATH_TOKEN_RE.lastIndex = 0;
    const matches: Array<{ start: number; end: number; raw: string }> = [];
    let match: RegExpExecArray | null = BLOCK_PATH_TOKEN_RE.exec(fullText);
    while (match) {
      const raw = match[0];
      if (raw && isLikelyFilePath(raw)) {
        matches.push({ start: match.index, end: match.index + raw.length, raw });
      }
      match = BLOCK_PATH_TOKEN_RE.exec(fullText);
    }

    for (const { start, end, raw } of matches.reverse()) {
      const startPosition = findTextPosition(textNodes, start);
      const endPosition = findTextPosition(textNodes, end);
      if (!startPosition || !endPosition) {
        continue;
      }

      const range = doc.createRange();
      range.setStart(startPosition.node, startPosition.offset);
      range.setEnd(endPosition.node, endPosition.offset);

      const span = doc.createElement('span');
      span.setAttribute(BLOCK_PATH_TOKEN_ATTR, 'true');
      span.textContent = raw;

      range.deleteContents();
      range.insertNode(span);
    }

    codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
  }
};

const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
  const parsed = parseFileReference(rawValue);
  if (!parsed || !isLikelyFilePathValue(parsed.path)) {
    return null;
  }

  const resolvedPath = isAbsolutePath(parsed.path)
    ? normalizePath(parsed.path)
    : toAbsolutePath(effectiveDirectory, parsed.path);
  if (!resolvedPath) {
    return null;
  }

  return {
    ...parsed,
    resolvedPath,
  };
};

const fileReferenceExists = (resolvedPath: string): Promise => {
  const normalizedPath = normalizePath(resolvedPath);
  if (!normalizedPath) {
    return Promise.resolve(false);
  }

  const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
  if (cached) {
    FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
    FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
    return cached;
  }

  const request = new Promise((resolve) => {
    const run = () => {
      activeFileReferenceStatCount += 1;
      void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
        method: 'GET',
        cache: 'no-store',
      })
        .then(async (response) => {
          if (!response.ok) {
            resolve(false);
            return;
          }
          const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
          resolve(payload?.exists !== false);
        })
        .catch(() => resolve(false))
        .finally(() => {
          activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
          pendingFileReferenceStats.shift()?.();
        });
    };

    if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
      run();
      return;
    }

    pendingFileReferenceStats.push(run);
  });

  const maxCacheEntries = getFileReferenceStatCacheMax();
  while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
    const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
    if (typeof oldest !== 'string') {
      break;
    }
    FILE_REFERENCE_STAT_CACHE.delete(oldest);
  }
  FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
  return request;
};

const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
  return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};

const useFileReferenceInteractions = ({
  containerRef,
  effectiveDirectory,
  editor,
  preferRuntimeEditor,
  enabled,
}: {
  containerRef: React.RefObject;
  effectiveDirectory: string;
  editor?: EditorAPI;
  preferRuntimeEditor?: boolean;
  enabled: boolean;
}) => {
  const annotationDebounceRef = React.useRef(null);

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) {
      return;
    }
    let cancelled = false;
    const fileReferenceLinkLimit = getFileReferenceLinkLimit();
    // On mobile surfaces, file-reference highlighting is disabled entirely — not
    // just visually. The annotation pass is what issues the filesystem `stat`
    // probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees
    // no probe requests are ever sent from a mobile runtime.
    const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime();

    const clearFileLinkAttributes = (candidate: HTMLElement) => {
      candidate.removeAttribute('data-openchamber-file-link');
      candidate.removeAttribute('data-openchamber-file-ref');
      candidate.removeAttribute('data-openchamber-file-path');
      if (candidate.getAttribute('title') === 'Open file') {
        candidate.removeAttribute('title');
      }
      if (candidate.tagName.toLowerCase() !== 'a') {
        candidate.removeAttribute('role');
        candidate.removeAttribute('tabindex');
      }
    };

    const clearAnnotatedFileLinks = () => {
      const annotated = container.querySelectorAll(FILE_LINK_SELECTOR);
      for (const candidate of Array.from(annotated)) {
        clearFileLinkAttributes(candidate);
      }
      unwrapBlockCodePathTokens(container);
    };

    if (!fileReferencesEnabled) {
      clearAnnotatedFileLinks();
      return;
    }

    const scheduleAnnotation = (delayMs = 0) => {
      if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
        window.clearTimeout(annotationDebounceRef.current);
      }
      if (typeof window === 'undefined') {
        annotateFileLinks();
        return;
      }
      annotationDebounceRef.current = window.setTimeout(() => {
        annotationDebounceRef.current = null;
        window.requestAnimationFrame(() => {
          if (!cancelled) {
            annotateFileLinks();
          }
        });
      }, delayMs);
    };

    const annotateFileLinks = () => {
      if (fileReferencesEnabled) {
        wrapBlockCodePathTokens(container);
      }
      const candidates = container.querySelectorAll(
        `[data-markdown="inline-code"], a, ${BLOCK_PATH_TOKEN_SELECTOR}`,
      );
      let linkedCount = 0;

      for (const candidate of Array.from(candidates)) {
        const rawCandidate = extractPathCandidateFromElement(candidate);
        const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
        clearFileLinkAttributes(candidate);

        if (!resolved) {
          continue;
        }

        if (linkedCount >= fileReferenceLinkLimit) {
          continue;
        }

        linkedCount += 1;

        const canGrantOutsideFile = isDesktopShell()
          && isDesktopLocalOriginActive()
          && !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
        const existsPromise = canGrantOutsideFile
          ? Promise.resolve(true)
          : fileReferenceExists(resolved.resolvedPath);

        void existsPromise.then((exists) => {
          if (cancelled || !exists || !container.contains(candidate)) {
            return;
          }

          const latestRawCandidate = extractPathCandidateFromElement(candidate);
          const latestResolved = getResolvedReference(latestRawCandidate, effectiveDirectory);
          if (!latestResolved || latestResolved.resolvedPath !== resolved.resolvedPath) {
            return;
          }

          candidate.setAttribute('data-openchamber-file-link', 'true');
          candidate.setAttribute('data-openchamber-file-ref', latestRawCandidate);
          candidate.setAttribute('data-openchamber-file-path', latestResolved.resolvedPath);
          candidate.setAttribute('title', 'Open file');
          if (candidate.tagName.toLowerCase() !== 'a') {
            candidate.setAttribute('role', 'button');
            candidate.setAttribute('tabindex', '0');
          }
        });
      }
    };

    const openFileReference = async (sourceElement: HTMLElement) => {
      const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
      const resolved = getResolvedReference(raw, effectiveDirectory);
      if (!resolved) {
        return;
      }

      const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
      if (preferRuntimeEditor && editor) {
        void editor.openFile(
          resolved.resolvedPath,
          Number.isFinite(resolved.line ?? Number.NaN)
            ? Math.max(1, Math.trunc(resolved.line as number))
            : undefined,
          Number.isFinite(resolved.column ?? Number.NaN)
            ? Math.max(1, Math.trunc(resolved.column as number))
            : undefined,
        );
        return;
      }

      if (!isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory)) {
        await ensureOutsideFileGrantForDesktop(resolved.resolvedPath, effectiveDirectory);
      }

      const uiStore = useUIStore.getState();
      if (Number.isFinite(resolved.line ?? Number.NaN)) {
        uiStore.openContextFileAtLine(
          contextDirectory,
          resolved.resolvedPath,
          Math.max(1, Math.trunc(resolved.line as number)),
          Number.isFinite(resolved.column ?? Number.NaN)
            ? Math.max(1, Math.trunc(resolved.column as number))
            : 1,
        );
      } else {
        uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
      }
    };

    const handleClick = (event: MouseEvent) => {
      const target = event.target;
      if (!(target instanceof Element)) {
        return;
      }

      const fileRefElement = target.closest(FILE_LINK_SELECTOR);
      if (!(fileRefElement instanceof HTMLElement)) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      void openFileReference(fileRefElement);
    };

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Enter' && event.key !== ' ') {
        return;
      }

      const target = event.target;
      if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      void openFileReference(target);
    };

    scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);

    const observer = new MutationObserver(() => {
      scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
    });
    observer.observe(container, {
      childList: true,
      subtree: true,
    });

    container.addEventListener('click', handleClick);
    container.addEventListener('keydown', handleKeyDown);

    return () => {
      cancelled = true;
      if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
        window.clearTimeout(annotationDebounceRef.current);
      }
      annotationDebounceRef.current = null;
      observer.disconnect();
      container.removeEventListener('click', handleClick);
      container.removeEventListener('keydown', handleKeyDown);
    };
  }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, enabled]);
};

const useMermaidInlineInteractions = ({
  containerRef,
  onShowPopup,
  enableFullscreen,
  enablePanZoom,
  allowMermaidWheelEvents,
}: {
  containerRef: React.RefObject;
  onShowPopup?: (content: ToolPopupContent) => void;
  enableFullscreen?: boolean;
  enablePanZoom?: boolean;
  allowMermaidWheelEvents?: boolean;
}) => {
  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) {
      return;
    }

    const handleMermaidClick = (event: MouseEvent) => {
      if (!enableFullscreen || !onShowPopup) {
        return;
      }

      const target = event.target;
      if (!(target instanceof Element)) {
        return;
      }

      if (target.closest('button, a, [role="button"]')) {
        return;
      }

      const block = target.closest(MERMAID_BLOCK_SELECTOR);
      if (!block) {
        return;
      }

      if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) {
        block.removeAttribute('data-mermaid-suppress-click');
        return;
      }

      const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
      const blockIndex = renderedBlocks.indexOf(block as HTMLElement);
      if (blockIndex < 0) {
        return;
      }

      const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null;
      if (!source || source.trim().length === 0) {
        return;
      }

      const filename = `Diagram ${blockIndex + 1}`;
      onShowPopup({
        open: true,
        title: filename,
        content: '',
        metadata: {
          tool: 'mermaid-preview',
          filename,
        },
        mermaid: {
          url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`,
          source,
          filename,
        },
      });
    };

    const handleInlineWheel = (event: WheelEvent) => {
      if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) {
        return;
      }

      const target = event.target;
      if (!(target instanceof Element)) {
        return;
      }

      const block = target.closest(MERMAID_BLOCK_SELECTOR);
      if (!block) {
        return;
      }

      // Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers.
      event.stopPropagation();
    };

    container.addEventListener('click', handleMermaidClick);
    container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true });

    return () => {
      container.removeEventListener('click', handleMermaidClick);
      container.removeEventListener('wheel', handleInlineWheel, true);
    };
  }, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]);
};

// ---------------------------------------------------------------------------
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
// ---------------------------------------------------------------------------

// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
// matter the cadence — text always keeps up with the incoming stream.
const TEXT_PACE_MS = 64;
const PACE_BASELINE_MS = 24;
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
const TEXT_SNAP = /[\s.,!?;:)\]]/;

const paceStep = (remaining: number): number => {
  const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
  return Math.max(1, Math.round(base * PACE_RATIO));
};

const nextRevealIndex = (text: string, start: number): number => {
  const end = Math.min(text.length, start + paceStep(text.length - start));
  for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
    if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
  }
  return end;
};

// Granular streaming reveal. Cheap because each step only re-runs the
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
// reconciliation of the markdown body.
const usePacedText = (content: string, streaming: boolean): string => {
  const [shown, setShown] = React.useState(() => (streaming ? 0 : content.length));
  const shownRef = React.useRef(shown);
  shownRef.current = shown;

  React.useEffect(() => {
    if (!streaming || typeof window === 'undefined') {
      setShown(content.length);
      return;
    }
    if (shownRef.current > content.length) {
      setShown(content.length);
    }

    let timer: number | null = null;
    const tick = () => {
      const current = Math.min(shownRef.current, content.length);
      if (current >= content.length) {
        timer = null;
        return;
      }
      setShown(nextRevealIndex(content, current));
      timer = window.setTimeout(tick, TEXT_PACE_MS);
    };

    if (shownRef.current < content.length) {
      timer = window.setTimeout(tick, TEXT_PACE_MS);
    }

    return () => {
      if (timer !== null) window.clearTimeout(timer);
    };
  }, [content, streaming]);

  if (!streaming) return content;
  return content.slice(0, Math.min(shown, content.length));
};

// Mermaid layout is expensive; `decorate` would otherwise re-render every
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
// so a stable diagram is laid out once and served from cache thereafter.
const MERMAID_RENDER_CACHE = new Map();
const MERMAID_RENDER_CACHE_MAX = 100;

const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
  const existing = MERMAID_RENDER_CACHE.get(key);
  if (existing) {
    MERMAID_RENDER_CACHE.delete(key);
    MERMAID_RENDER_CACHE.set(key, existing);
    return existing;
  }
  const value = compute();
  MERMAID_RENDER_CACHE.set(key, value);
  if (MERMAID_RENDER_CACHE.size > MERMAID_RENDER_CACHE_MAX) {
    const oldest = MERMAID_RENDER_CACHE.keys().next().value;
    if (oldest) MERMAID_RENDER_CACHE.delete(oldest);
  }
  return value;
};

const mermaidColorsFromTheme = (theme: Theme) => ({
  bg: theme.colors.surface.elevated,
  fg: theme.colors.surface.foreground,
  line: theme.colors.interactive.border,
  accent: theme.colors.primary.base,
  muted: theme.colors.surface.mutedForeground,
  surface: theme.colors.surface.muted,
  border: theme.colors.interactive.border,
  transparent: true,
  font: 'system-ui, sans-serif',
});

const useDecorateContext = (
  currentTheme: Theme,
  deferCodeLineNumberSync: boolean,
  onPreviewLoopback?: (url: string) => void,
  mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS,
): DecorateContext => {
  const { t } = useI18n();
  const labels: DecorateLabels = React.useMemo(() => ({
    copy: t('markdownRenderer.code.actions.copyTitle'),
    copied: t('markdownRenderer.code.actions.copiedTitle'),
    enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
    disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
    copyTable: t('markdownRenderer.table.actions.copyTitle'),
    downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
    copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
    downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'),
    zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'),
    zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'),
    resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'),
    previewLabel: t('terminalView.preview.open'),
    previewTitle: t('terminalView.preview.openTitle'),
  }), [t]);

  const codeBlockLineWrap = useUIStore((state) => state.codeBlockLineWrap);
  const setCodeBlockLineWrap = useUIStore((state) => state.setCodeBlockLineWrap);
  const toggleCodeBlockLineWrap = React.useCallback(() => {
    setCodeBlockLineWrap(!useUIStore.getState().codeBlockLineWrap);
  }, [setCodeBlockLineWrap]);

  return React.useMemo(() => {
    const colors = mermaidColorsFromTheme(currentTheme);
    const mode = useUIStore.getState().mermaidRenderingMode;
    const themeId = currentTheme.metadata?.id ?? 'theme';
    const renderMermaid = (source: string): MermaidRender =>
      cachedMermaidRender(`${themeId}:${mode}:${source}`, () => {
        try {
          if (mode === 'ascii') return { ascii: renderMermaidASCII(source) };
          return { svg: renderMermaidSVG(source, colors) };
        } catch {
          return {};
        }
      });
    return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
  }, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
};

// Runs the async render pipeline into the container and keeps a stable
// delegated interaction listener attached.
const useMorphdomMarkdown = ({
  containerRef,
  text,
  streaming,
  cacheKey,
  syntaxVars,
  ctx,
}: {
  containerRef: React.RefObject;
  text: string;
  streaming: boolean;
  cacheKey: string;
  syntaxVars: Record;
  ctx: DecorateContext;
}) => {
  React.useEffect(() => {
    ensureMarkdownShikiTheme();
  }, []);

  const mermaidViewerRef = React.useRef | null>(null);
  const refreshMermaidViewers = React.useCallback(() => {
    const container = containerRef.current;
    if (!container) {
      return;
    }
    if (!mermaidViewerRef.current) {
      if (!shouldRefreshMermaidViewers(container)) {
        return;
      }
      mermaidViewerRef.current = createMermaidViewerRegistry(container);
      return;
    }
    mermaidViewerRef.current.refresh();
  }, [containerRef]);

  // Synchronous first paint: while the async parse is in-flight, show escaped
  // plain text immediately so there is no blank frame on initial mount. Only
  // runs when the target is empty — subsequent updates keep the prior rich DOM
  // until the next async render morphs in (no flash). Mirrors OpenCode's
  // `initialValue: fallback(text)` resource pattern.
  React.useLayoutEffect(() => {
    const container = containerRef.current;
    const target = container?.querySelector('[data-markdown-content]') ?? container;
    if (!target) return;
    if (text && target.childNodes.length === 0) {
      const block = document.createElement('div');
      block.setAttribute('data-md-block', '');
      // `display:contents` keeps margin-collapsing/spacing identical to a flat
      // HTML body — the wrapper exists only for per-block reconciliation.
      block.style.display = 'contents';
      block.innerHTML = renderMarkdownSync(text);
      // Decorate synchronously too: wrap code blocks in their framed card,
      // mark inline code, build table controls, etc. The async pass re-decorates
      // its own DOM before morphing, so without this the first paint shows bare
      // 
/tables that "snap" into their decorated form a tick later. Matching
      // the structure here keeps the async morph to syntax colors only.
      decorateMarkdown(block, ctx);
      target.appendChild(block);
      if (shouldRefreshMermaidViewers(block)) {
        refreshMermaidViewers();
      }
    }
  }, [containerRef, text, ctx, refreshMermaidViewers]);

  React.useEffect(() => () => {
    mermaidViewerRef.current?.cleanup();
    mermaidViewerRef.current = null;
  }, []);

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const target = container.querySelector('[data-markdown-content]') ?? container;
    let active = true;

    void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
      if (!active) return;
      const existing = Array.from(target.children) as HTMLElement[];

      // Reconcile per block: only re-morph blocks whose content changed, leaving
      // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
      // to the trailing (growing) block instead of the whole message.
      blocks.forEach((block, index) => {
        let el = existing[index];
        if (!el) {
          el = document.createElement('div');
          el.setAttribute('data-md-block', '');
          el.style.display = 'contents';
          target.appendChild(el);
        }
        if (el.getAttribute('data-md-id') === block.id) return;

        const temp = document.createElement('div');
        temp.innerHTML = block.html;
        decorateMarkdown(temp, ctx);
        const hadMermaidBlock = shouldRefreshMermaidViewers(el);
        const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
        morphdom(el, temp, {
          childrenOnly: true,
          onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
        });
        el.setAttribute('data-md-id', block.id);
        if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
          refreshMermaidViewers();
        }
      });

      // Remove any trailing block elements no longer present.
      const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
      let removedMermaidBlock = false;
      for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
        const removed = existing[i];
        if (removed && shouldRefreshMermaidViewers(removed)) {
          removedMermaidBlock = true;
        }
        removed?.remove();
      }
      if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
        refreshMermaidViewers();
      }

      if (!ctx.deferCodeLineNumberSync) {
        scheduleMarkdownCodeLineNumberSync(target);
      }
    });

    return () => {
      active = false;
    };
  }, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    return attachMarkdownInteractions(container, ctx);
  }, [containerRef, ctx]);

  // Apply syntax CSS variables imperatively so they survive morphdom updates.
  React.useEffect(() => {
    const container = containerRef.current;
    const target = container?.querySelector('[data-markdown-content]') ?? container;
    if (!target) return;
    for (const [key, value] of Object.entries(syntaxVars)) {
      target.style.setProperty(key, value);
    }
  }, [containerRef, syntaxVars]);

  React.useEffect(() => {
    const container = containerRef.current;
    const target = container?.querySelector('[data-markdown-content]') ?? container;
    if (!target) return;
    if (ctx.deferCodeLineNumberSync) return;
    applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
  }, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);

  React.useEffect(() => {
    const container = containerRef.current;
    const target = container?.querySelector('[data-markdown-content]') ?? container;
    if (!target || typeof ResizeObserver === 'undefined') return;
    let frame: number | null = null;
    const observer = new ResizeObserver(() => {
      if (frame !== null) window.cancelAnimationFrame(frame);
      frame = window.requestAnimationFrame(() => {
        frame = null;
        syncMarkdownCodeLineNumbers(target);
      });
    });
    observer.observe(target);
    return () => {
      observer.disconnect();
      if (frame !== null) window.cancelAnimationFrame(frame);
    };
  }, [containerRef]);
};

const markdownContentClassName = (variant: MarkdownVariant): string =>
  variant === 'tool'
    ? 'markdown-content markdown-tool'
    : variant === 'reasoning'
      ? 'markdown-content markdown-reasoning'
      : 'markdown-content leading-relaxed';

const MarkdownRendererImpl: React.FC = ({
  content,
  part,
  messageId,
  isAnimated = true,
  skipFadeIn = false,
  className,
  isStreaming = false,
  disableStreamAnimation = false,
  variant = 'assistant',
  onShowPopup,
  enableFileReferences = true,
}) => {
  const currentTheme = useCurrentMermaidTheme();
  const { editor, runtime } = useRuntimeAPIs();
  const containerRef = React.useRef(null);
  const effectiveDirectory = useEffectiveDirectory() ?? '';
  const openContextPreview = useUIStore((state) => state.openContextPreview);

  const handlePreviewLoopback = React.useCallback((url: string) => {
    if (!effectiveDirectory) return;
    openContextPreview(effectiveDirectory, url);
  }, [effectiveDirectory, openContextPreview]);

  const live = isStreaming && !disableStreamAnimation;
  const pacedText = usePacedText(content, live);

  useMermaidInlineInteractions({
    containerRef,
    onShowPopup,
    enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
    enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls,
  });
  useFileReferenceInteractions({
    containerRef,
    effectiveDirectory,
    editor,
    preferRuntimeEditor: runtime.isVSCode,
    enabled: enableFileReferences && !isStreaming,
  });
  useExternalLinkInteractions({ containerRef });

  const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
  const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
  const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;

  useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });

  const markdownContent = (
    
); if (isAnimated) { return ( {markdownContent} ); } return markdownContent; }; export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) => { return prev.content === next.content && prev.isStreaming === next.isStreaming && prev.disableStreamAnimation === next.disableStreamAnimation && prev.variant === next.variant && prev.isAnimated === next.isAnimated && prev.skipFadeIn === next.skipFadeIn && prev.className === next.className && prev.messageId === next.messageId && prev.onShowPopup === next.onShowPopup && prev.enableFileReferences === next.enableFileReferences && prev.part?.id === next.part?.id; }); const SimpleMarkdownRendererImpl: React.FC<{ content: string; className?: string; variant?: MarkdownVariant; disableLinkSafety?: boolean; stripFrontmatter?: boolean; onShowPopup?: (content: ToolPopupContent) => void; mermaidControls?: MermaidControlOptions; allowMermaidWheelEvents?: boolean; enableFileReferences?: boolean; }> = ({ content, className, variant = 'assistant', disableLinkSafety, stripFrontmatter = false, onShowPopup, mermaidControls = DEFAULT_MERMAID_CONTROLS, allowMermaidWheelEvents = false, enableFileReferences = true, }) => { const { editor, runtime } = useRuntimeAPIs(); const currentTheme = useCurrentMermaidTheme(); const containerRef = React.useRef(null); const effectiveDirectory = useEffectiveDirectory() ?? ''; const renderedContent = React.useMemo( () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content), [content, stripFrontmatter], ); useMermaidInlineInteractions({ containerRef, onShowPopup, enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED, enablePanZoom: mermaidControls.showPanZoomControls, allowMermaidWheelEvents, }); useFileReferenceInteractions({ containerRef, effectiveDirectory, editor, preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences, }); useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); useMorphdomMarkdown({ containerRef, text: renderedContent, streaming: false, cacheKey: `simple:${variant}`, syntaxVars, ctx, }); return (
); }; export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => { const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS; const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS; return prev.content === next.content && prev.variant === next.variant && prev.className === next.className && prev.disableLinkSafety === next.disableLinkSafety && prev.stripFrontmatter === next.stripFrontmatter && prev.onShowPopup === next.onShowPopup && prevMermaidControls.download === nextMermaidControls.download && prevMermaidControls.copy === nextMermaidControls.copy && prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls && prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents && prev.enableFileReferences === next.enableFileReferences; });