import type { ToolCallMessagePartStatus } from "@assistant-ui/react"; import { AlertCircleIcon, LoaderIcon, XCircleIcon } from "lucide-react"; import { Highlight, themes as prismThemes } from "prism-react-renderer"; import { type FC, type ReactNode, useSyncExternalStore } from "react"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; // ─── Shared shell ─────────────────────────────────────────────────────────── // Compact collapsible row: [status icon] [tool-icon] LABEL summary… // Built directly on Collapsible primitive so the trigger row is fully custom. interface ToolShellProps { icon: ReactNode; label: string; summary: ReactNode; status?: ToolCallMessagePartStatus; children?: ReactNode; } const StatusIcon: FC<{ status?: ToolCallMessagePartStatus }> = ({ status }) => { if (!status || status.type === "complete") return null; if (status.type === "running") return ; if (status.type === "requires-action") return ; if (status.type === "incomplete") return ; return null; }; export const ToolShell: FC = ({ icon, label, summary, status, children }) => { const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const errorText = status?.type === "incomplete" && status.error ? (typeof status.error === "string" ? status.error : JSON.stringify(status.error)) : null; return ( {icon} {label} {summary}
{errorText && (
{errorText}
)} {children}
); }; // ─── Theme detection ──────────────────────────────────────────────────────── // Subscribes to `.dark` class changes on so diff viewer and syntax // highlighter switch themes in sync with the rest of the app. function subscribeDarkMode(cb: () => void): () => void { const obs = new MutationObserver(cb); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => obs.disconnect(); } function getDarkModeSnapshot(): boolean { return document.documentElement.classList.contains("dark"); } function useIsDarkMode(): boolean { return useSyncExternalStore( subscribeDarkMode, getDarkModeSnapshot, () => false, // SSR fallback — light by default ); } // ─── CodeBlock ─────────────────────────────────────────────────────────────── // Syntax-highlighted, via prism-react-renderer. Falls back to plain pre if // lang is omitted. `plain` skips tokenization entirely (for raw shell output // where pretending to highlight adds nothing). const EXT_TO_LANG: Record = { ts: "tsx", tsx: "tsx", js: "jsx", jsx: "jsx", mjs: "jsx", cjs: "jsx", json: "json", md: "markdown", mdx: "markdown", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", kt: "kotlin", c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", sh: "bash", bash: "bash", zsh: "bash", fish: "bash", yml: "yaml", yaml: "yaml", toml: "toml", ini: "ini", sql: "sql", html: "markup", htm: "markup", xml: "markup", svg: "markup", css: "css", scss: "scss", }; export function langFromPath(path?: string): string | undefined { if (!path) return undefined; const m = path.match(/\.([a-z0-9]+)$/i); return m ? EXT_TO_LANG[m[1].toLowerCase()] : undefined; } interface CodeBlockProps { children: string; lang?: string; className?: string; /** Skip tokenization — use for raw terminal output. */ plain?: boolean; } export const CodeBlock: FC = ({ children, lang, className, plain }) => { const isDark = useIsDarkMode(); const baseCls = "max-h-64 overflow-auto rounded-md border border-border/60 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-[1.55] whitespace-pre-wrap break-all"; if (plain || !lang) { return
{children}
; } const theme = isDark ? prismThemes.vsDark : prismThemes.vsLight; return ( {({ tokens, getLineProps, getTokenProps }) => (
          {tokens.map((line, i) => {
            const { key: _lineKey, ...lineProps } = getLineProps({ line });
            return (
              
{line.map((token, j) => { const { key: _tokenKey, ...tokenProps } = getTokenProps({ token }); return ; })}
); })}
)}
); }; // Kept as an alias so other tools (Mono-style plain output) still read well. export const Mono: FC<{ children: ReactNode; className?: string }> = ({ children, className }) => ( {typeof children === "string" ? children : String(children)} ); // Coerce a tool result (which may be string / object / { error }) into plain // text. Keeps the per-tool UIs free of this plumbing. export function resultText(result: unknown): string | null { if (result == null) return null; if (typeof result === "string") return result; if (typeof result === "object") { const r = result as Record; if (typeof r.error === "string") return r.error; if (typeof r.output === "string") return r.output; return JSON.stringify(result, null, 2); } return String(result); } // ─── FileDiff ──────────────────────────────────────────────────────────────── // Reusable single-hunk unified diff backed by react-diff-viewer-continued. // Provides: line numbers, word-level highlight, split/unified toggle, and // automatic light/dark theming. // Palettes for the diff viewer. Keyed on dark-mode flag. Intentionally // ignores the rdv built-in themes because they use flat reds/greens that // blow out contrast against the chat surface. const DIFF_PALETTE = { dark: { diffViewerBackground: "transparent", diffViewerColor: "#FAFAFA", addedBackground: "rgba(34, 197, 94, 0.14)", addedColor: "#86efac", removedBackground: "rgba(239, 68, 68, 0.14)", removedColor: "#fca5a5", wordAddedBackground: "rgba(34, 197, 94, 0.35)", wordRemovedBackground: "rgba(239, 68, 68, 0.35)", addedGutterBackground: "rgba(34, 197, 94, 0.20)", removedGutterBackground: "rgba(239, 68, 68, 0.20)", gutterBackground: "transparent", gutterBackgroundDark: "transparent", highlightBackground: "rgba(255, 255, 255, 0.04)", highlightGutterBackground: "rgba(255, 255, 255, 0.06)", codeFoldGutterBackground: "transparent", codeFoldBackground: "transparent", emptyLineBackground: "transparent", gutterColor: "rgba(255, 255, 255, 0.35)", addedGutterColor: "rgba(134, 239, 172, 0.85)", removedGutterColor: "rgba(252, 165, 165, 0.85)", codeFoldContentColor: "rgba(255, 255, 255, 0.5)", diffViewerTitleBackground: "transparent", diffViewerTitleColor: "rgba(255, 255, 255, 0.6)", diffViewerTitleBorderColor: "rgba(255, 255, 255, 0.1)", }, light: { diffViewerBackground: "transparent", diffViewerColor: "#09090B", addedBackground: "rgba(34, 197, 94, 0.12)", addedColor: "#166534", removedBackground: "rgba(239, 68, 68, 0.12)", removedColor: "#991b1b", wordAddedBackground: "rgba(34, 197, 94, 0.32)", wordRemovedBackground: "rgba(239, 68, 68, 0.32)", addedGutterBackground: "rgba(34, 197, 94, 0.18)", removedGutterBackground: "rgba(239, 68, 68, 0.18)", gutterBackground: "transparent", gutterBackgroundDark: "transparent", highlightBackground: "rgba(0, 0, 0, 0.04)", highlightGutterBackground: "rgba(0, 0, 0, 0.06)", codeFoldGutterBackground: "transparent", codeFoldBackground: "transparent", emptyLineBackground: "transparent", gutterColor: "rgba(9, 9, 11, 0.45)", addedGutterColor: "rgba(22, 101, 52, 0.85)", removedGutterColor: "rgba(153, 27, 27, 0.85)", codeFoldContentColor: "rgba(9, 9, 11, 0.55)", diffViewerTitleBackground: "transparent", diffViewerTitleColor: "rgba(9, 9, 11, 0.6)", diffViewerTitleBorderColor: "rgba(9, 9, 11, 0.1)", }, }; export const FileDiff: FC<{ oldStr: string; newStr: string }> = ({ oldStr, newStr }) => { const isDark = useIsDarkMode(); return (
); }; // ─── Markdown helper ───────────────────────────────────────────────────────── export const Markdown: FC<{ text: string }> = ({ text }) => (
{text}
); // ─── MCP tool name parser ──────────────────────────────────────────────────── /** * Split an MCP tool name into namespace + tool parts. * `mcp.chrome_devtools.click` → { ns: "chrome_devtools", name: "click" } * `mcp__chrome_devtools__click` → { ns: "chrome_devtools", name: "click" } * Returns null for non-MCP names. */ export function parseMcpToolName(toolName: string): { ns: string; name: string } | null { if (toolName.startsWith("mcp.")) { const rest = toolName.slice(4); const idx = rest.lastIndexOf("."); if (idx === -1) return { ns: "mcp", name: rest }; return { ns: rest.slice(0, idx), name: rest.slice(idx + 1) }; } if (!toolName.startsWith("mcp__")) return null; const rest = toolName.slice(5); const idx = rest.indexOf("__"); if (idx === -1) return { ns: "mcp", name: rest }; return { ns: rest.slice(0, idx), name: rest.slice(idx + 2) }; }