/* Copyright 2026 Marimo. All rights reserved. */ import { ArrowDownIcon, ChevronUpIcon } from "lucide-react"; import { useId, useLayoutEffect, useRef, useState } from "react"; import { CopyClipboardIcon } from "@/components/icons/copy-icon"; import { useEventListener } from "@/hooks/useEventListener"; import { cn } from "@/utils/cn"; function hasSelection(element: HTMLElement) { const selection = element.ownerDocument.getSelection(); return ( selection && !selection.isCollapsed && selection.containsNode(element, true) ); } export type StartupOutputState = "running" | "succeeded" | "failed"; export function StartupOutput({ logs, label, state = "running", }: { logs: string; label: string; /** Running keeps the newest line bright; failed keeps all of it bright. */ state?: StartupOutputState; }) { const [expanded, setExpanded] = useState(false); const [following, setFollowing] = useState(true); const outputRef = useRef(null); const toggleRef = useRef(null); const outputId = useId(); const lines = logs.trimEnd().split(/\r?\n/); const emphasis = state === "failed" ? "text-foreground" : state === "running" ? "last:text-foreground" : undefined; useEventListener(outputRef, "keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); setExpanded(false); toggleRef.current?.focus(); } }); useEventListener(document, "selectionchange", () => { if (outputRef.current && hasSelection(outputRef.current)) { setFollowing(false); } }); useLayoutEffect(() => { const output = outputRef.current; if (!output) { return; } const previous = output.textContent ?? ""; // Append without replacing selected text nodes as more output arrives. if (logs.startsWith(previous)) { if (logs.length > previous.length) { output.append(document.createTextNode(logs.slice(previous.length))); } } else { output.textContent = logs; } if (expanded && following && !hasSelection(output)) { output.scrollTop = output.scrollHeight; } }, [logs, expanded, following]); if (!logs.trim()) { return null; } return (
{expanded && ( )}
); }