/* Copyright 2026 Marimo. All rights reserved. */ import React, { memo, Suspense, useMemo, useRef } from "react"; import { type CellId, CellOutputId } from "@/core/cells/ids"; import type { CellOutput, OutputMessage } from "@/core/kernel/messages"; import { cn } from "@/utils/cn"; import { logNever } from "../../utils/assertNever"; import { ErrorBoundary } from "./boundary/ErrorBoundary"; import { HtmlOutput } from "./output/HtmlOutput"; import { ImageOutput } from "./output/ImageOutput"; import { JsonOutput } from "./output/JsonOutput"; import { MarimoErrorOutput } from "./output/MarimoErrorOutput"; import { TextOutput } from "./output/TextOutput"; import { VideoOutput } from "./output/VideoOutput"; import "./output/Outputs.css"; import { useAtomValue } from "jotai"; import { ChevronsDownUpIcon, ChevronsUpDownIcon, ExpandIcon, } from "lucide-react"; import { tooltipHandler } from "@/components/charts/tooltip"; import { useExpandedOutput } from "@/core/cells/outputs"; import { viewStateAtom } from "@/core/mode"; import { useIframeCapabilities } from "@/hooks/useIframeCapabilities"; import { useOverflowDetection } from "@/hooks/useOverflowDetection"; import { renderHTML } from "@/plugins/core/RenderHTML"; import { Banner } from "@/plugins/impl/common/error-banner"; import type { TopLevelFacetedUnitSpec } from "@/plugins/impl/data-explorer/queries/types"; import { getContainerWidth } from "@/plugins/impl/vega/utils"; import { useTheme } from "@/theme/useTheme"; import { Events } from "@/utils/events"; import { invariant } from "@/utils/invariant"; import { processMimeBundle } from "@/utils/mime-types"; import { Objects } from "@/utils/objects"; import { LazyVegaEmbed } from "../charts/lazy"; import { ChartLoadingState } from "../data-table/charts/components/chart-states"; import { Button } from "../ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tooltip } from "../ui/tooltip"; import { CsvViewer } from "./file-tree/renderers"; import { MarimoTracebackOutput } from "./output/MarimoTracebackOutput"; import { renderMimeIcon } from "./renderMimeIcon"; const METADATA_KEY = "__metadata__"; export type MimeType = OutputMessage["mimetype"]; type MimeBundleWithoutMetadata = Record; type MimeBundle = MimeBundleWithoutMetadata & { [METADATA_KEY]?: Record; }; type MimeBundleOrTuple = MimeBundle | [MimeBundle, { [key: string]: unknown }]; export type OnRefactorWithAI = (opts: { prompt: string; triggerImmediately: boolean; }) => void; /** * Renders an output based on an OutputMessage. */ export const OutputRenderer: React.FC<{ message: Pick; cellId?: CellId; onRefactorWithAI?: OnRefactorWithAI; wrapText?: boolean; metadata?: { width?: number; height?: number }; renderFallback?: (mimetype: MimeType) => React.ReactNode; }> = memo((props) => { const { message, onRefactorWithAI, cellId, wrapText, metadata, renderFallback, } = props; const { theme } = useTheme(); // Memoize parsing the json data const parsedJsonData = useMemo(() => { const data = message.data; switch (message.mimetype) { case "application/json": case "application/vnd.marimo+mimebundle": case "application/vnd.vegalite.v5+json": case "application/vnd.vega.v5+json": case "application/vnd.vegalite.v6+json": case "application/vnd.vega.v6+json": return typeof data === "string" ? JSON.parse(data) : data; default: return; } }, [message.mimetype, message.data]); const { channel, data, mimetype } = message; if (data == null) { return null; } // TODO(akshayka): audio; pdf; text/csv; excel?; text/css; text/javascript switch (mimetype) { case "text/html": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); // We don't sanitize HTML in text/html to allow for iframes or rich javascript content. return ( ); case "text/plain": case "text/password": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); return ; case "application/json": // TODO: format is 'auto', but should make configurable once cells can // support config return ( ); case "image/png": case "image/tiff": case "image/avif": case "image/bmp": case "image/gif": case "image/jpeg": case "image/svg+xml": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); if ( mimetype === "image/svg+xml" && !data.startsWith("data:image/svg+xml;base64,") ) { return renderHTML({ html: data, alwaysSanitizeHtml: true }); } return ( ); case "video/mp4": case "video/mpeg": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); return ; case "application/vnd.marimo+error": invariant(Array.isArray(data), "Expected array data"); return ; case "application/vnd.marimo+traceback": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); return ( ); case "text/csv": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); return ; case "text/latex": case "text/markdown": invariant( typeof data === "string", `Expected string data for mime=${mimetype}. Got ${typeof data}`, ); return ( ); case "application/vnd.vegalite.v5+json": case "application/vnd.vega.v5+json": case "application/vnd.vegalite.v6+json": case "application/vnd.vega.v6+json": return ( }> ); case "application/vnd.marimo+mimebundle": return ( } /> ); case "application/vnd.jupyter.widget-view+json": return ( Jupyter widgets are not supported in marimo.
Please migrate this widget to{" "} anywidget .
); default: logNever(mimetype); if (renderFallback) { return renderFallback(mimetype); } return (
Unsupported mimetype: {mimetype}
); } }); OutputRenderer.displayName = "OutputRenderer"; /** * Renders a mimebundle output. */ const MimeBundleOutputRenderer: React.FC<{ channel: OutputMessage["channel"]; data: MimeBundleOrTuple; cellId?: CellId; }> = memo(({ data, channel, cellId }) => { const mimebundle = Array.isArray(data) ? data[0] : data; const { mode } = useAtomValue(viewStateAtom); const appView = mode === "present" || mode === "read"; // Extract metadata if present (e.g., to maintain a constant display size regardless of DPI/PPI) const metadata = mimebundle[METADATA_KEY]; // Filter out metadata from the mime entries and type narrow const rawEntries = Objects.entries(mimebundle as Record) .filter(([key]) => key !== METADATA_KEY) .map(([mime, data]) => [mime, data] as [MimeType, CellOutput["data"]]); // Apply precedence ordering and hiding rules const { entries: mimeEntries } = processMimeBundle(rawEntries); // If there is none, return null const first = mimeEntries[0]?.[0]; if (!first) { return null; } // If there is only one mime type, render it directly if (mimeEntries.length === 1) { return ( ); } return (
{mimeEntries.map(([mime]) => ( {renderMimeIcon(mime)} ))}
{mimeEntries.map(([mime, output]) => ( ))}
); }); MimeBundleOutputRenderer.displayName = "MimeBundleOutputRenderer"; interface OutputAreaProps { output: OutputMessage | null; cellId: CellId; stale: boolean; loading: boolean; /** * Whether to allow expanding the output * This shows the expand button and allows the user to expand the output */ allowExpand: boolean; /** * Whether to force expand the output * When true, there will be no expand button and the output will be expanded. */ forceExpand?: boolean; className?: string; } export const OutputArea = React.memo( ({ output, cellId, stale, loading, allowExpand, forceExpand, className, }: OutputAreaProps) => { if (output == null) { return null; } if (output.channel === "output" && output.data === "") { return null; } // TODO(akshayka): More descriptive title // 1. This output is stale (this cell has been edited but not run) // 2. This output is stale (this cell is queued to run) // 3. This output is stale (its inputs have changed) const title = stale ? "This output is stale" : undefined; const Container = allowExpand ? ExpandableOutput : Div; return ( ); }, ); OutputArea.displayName = "OutputArea"; const Div = React.forwardRef< HTMLDivElement, React.ComponentPropsWithoutRef<"div"> >((props, ref) =>
); Div.displayName = "Div"; /** * Detects if there is overflow in the output area and adds a button to optionally expand */ const ExpandableOutput = React.memo( ({ cellId, children, forceExpand, ...props }: React.HTMLProps & { cellId: CellId; forceExpand?: boolean; }) => { const containerRef = useRef(null); const [isExpanded, setIsExpanded] = useExpandedOutput(cellId); const isOverflowing = useOverflowDetection(containerRef); const { hasFullscreen } = useIframeCapabilities(); return ( <>
{hasFullscreen && ( )} {(isOverflowing || isExpanded) && !forceExpand && ( )}
{children}
); }, ); ExpandableOutput.displayName = "ExpandableOutput";