/* Copyright 2026 Marimo. All rights reserved. */ import { AnsiUp } from "ansi_up"; import parse, { type DOMNode, Text } from "html-react-parser"; import React, { useMemo } from "react"; import { useInstallPackages } from "@/core/packages/useInstallPackage"; import { Events } from "@/utils/events"; import { parseContent } from "@/utils/url-parser"; const ansiUp = new AnsiUp(); /** * Helper to clean ANSI escape codes from text */ export const cleanAnsiCodes = (text: string): string => { // Remove ANSI escape sequences (ESC[ followed by numbers/semicolons and 'm') // Using String.fromCharCode to avoid linter error const ansiRegex = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); return text.replaceAll(ansiRegex, ""); }; // Regex to match install commands: pip install, uv add, uv pip install const INSTALL_COMMAND_REGEX = /(pip\s+install|uv\s+add|uv\s+pip\s+install)\s+/gi; /** * Parse install command to extract a single package name * Supports: pip install, uv add, uv pip install * Handles flags like -U, --upgrade that can appear before or after the package name */ function parsePipInstallCommand( text: string, ): { package: string; endIndex: number } | null { INSTALL_COMMAND_REGEX.lastIndex = 0; const match = INSTALL_COMMAND_REGEX.exec(text); if (!match) { return null; } const commandEndIndex = match.index + match[0].length; const afterCommand = text.slice(commandEndIndex); // Skip any flags (tokens starting with -) // Split by whitespace and find the first non-flag token const tokens = afterCommand.split(/\s+/); let packageName = ""; let packageStartOffset = 0; for (const token_ of tokens) { const token = token_.trim(); if (!token) { continue; } // Skip flags (anything starting with -) if (token.startsWith("-")) { // Add to offset: token length + space packageStartOffset += token.length + 1; continue; } // Found the package name - extract it using character matching // Match until we hit a character that's not valid in package names const packageMatch = token.match(/^[\w,.[\]-]+/); if (packageMatch) { packageName = packageMatch[0]; break; } break; } if (!packageName) { return null; } const endIndex = commandEndIndex + packageStartOffset + packageName.length; return { package: packageName, endIndex }; } /** * Type for a replacer function that can transform DOM nodes during parsing * Returns a React element, string, boolean, null, or undefined (to skip replacement) */ type Replacer = ( domNode: DOMNode, ) => React.ReactElement | string | boolean | null | undefined; /** * Helper function to process text content for URLs and images * This is used by multiple replacers to avoid code duplication */ export function processTextForUrls( text: string, keyPrefix = "", ): React.ReactNode { if (!text) { return null; } // Quick check to avoid unnecessary parsing if (!/https?:\/\//.test(text)) { return text; } const parts = parseContent(text); // If no URLs detected, return original text if (parts.length === 1 && parts[0].type === "text") { return text; } // Render parts with clickable links return ( <> {parts.map((part, idx) => { const key = keyPrefix ? `${keyPrefix}-${idx}` : idx; if (part.type === "url") { const cleanUrl = cleanAnsiCodes(part.url); return ( {cleanUrl} ); } if (part.type === "image") { // For console output, just render images as links const cleanUrl = cleanAnsiCodes(part.url); return ( {cleanUrl} ); } return {part.value}; })} ); } const InstallPackageLink = ({ packages, children, }: { packages: string[]; children: React.ReactNode; }) => { const { handleInstallPackages } = useInstallPackages(); return ( ); }; /** * Replacer that detects pip install commands and renders an install button */ export const pipInstallReplacer: Replacer = (domNode: DOMNode) => { // Only process text nodes if (!(domNode instanceof Text)) { return undefined; } const textContent = cleanAnsiCodes(domNode.data); // Quick check to avoid unnecessary regex if (!/(pip\s+install|uv\s+add|uv\s+pip\s+install)/i.test(textContent)) { return undefined; } // Find all matches in the text const matches: { match: RegExpExecArray; result: { package: string; endIndex: number }; }[] = []; // Create a new regex for iteration (don't reuse the global one) const regex = /(pip\s+install|uv\s+add|uv\s+pip\s+install)\s+/gi; let match: RegExpExecArray | null; while ((match = regex.exec(textContent)) !== null) { const startIndex = match.index; const textFromMatch = textContent.slice(startIndex); const result = parsePipInstallCommand(textFromMatch); if (result) { matches.push({ match, result }); } } // If no valid matches found, return undefined if (matches.length === 0) { return undefined; } // Build the result by splitting text into segments const segments: React.ReactNode[] = []; let lastIndex = 0; matches.forEach((matchInfo, idx) => { const { match, result } = matchInfo; const startIndex = match.index; const endIndex = startIndex + result.endIndex; // Add text before this match (with URL processing) if (lastIndex < startIndex) { const beforeText = textContent.slice(lastIndex, startIndex); segments.push( {processTextForUrls(beforeText, `before-${idx}`)} , ); } // Add the install button const commandText = textContent.slice(startIndex, endIndex); segments.push( {commandText} , ); lastIndex = endIndex; }); // Add any remaining text after the last match if (lastIndex < textContent.length) { const afterText = textContent.slice(lastIndex); segments.push( {processTextForUrls(afterText, "after")} , ); } return <>{segments}; }; /** * Replacer that detects URLs in text nodes and makes them clickable */ export const urlReplacer: Replacer = (domNode: DOMNode) => { // Only process text nodes if (!(domNode instanceof Text)) { return undefined; } const textContent = domNode.data; // Check if text contains URLs (fast check before parsing) if (!/https?:\/\//.test(textContent)) { return undefined; } // Use the shared helper to process URLs const result = processTextForUrls(textContent); // If no change was made (just plain text), return undefined to let other replacers try if (typeof result === "string") { return undefined; } return <>{result}; }; /** * Creates a composed replacer function from multiple replacers * Replacers are applied in order, and the first one that returns a value wins */ export const composeReplacers = (...replacers: Replacer[]): Replacer => { return (domNode: DOMNode) => { for (const replacer of replacers) { const result = replacer(domNode); if (result !== undefined) { return result; } } return undefined; }; }; /** * Convert text to React element with ANSI colors and custom replacers applied */ export const renderTextWithReplacers = ( text: string, replacer: Replacer, ): React.ReactNode => { const html = ansiUp.ansi_to_html(text); const content = parse(html, { replace: (domNode: DOMNode) => { return replacer(domNode); }, }); return {content}; }; /** * Convert text to React element with ANSI colors, clickable URLs, and pip install buttons */ export const RenderTextWithLinks = ({ text }: { text: string }) => { const content = useMemo(() => { return renderTextWithReplacers( text, composeReplacers(pipInstallReplacer, urlReplacer), ); }, [text]); return <>{content}; };