/* Copyright 2026 Marimo. All rights reserved. */ import { ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, WrapTextIcon, } from "lucide-react"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ToggleButton } from "react-aria-components"; import { DebuggerControls } from "@/components/debugger/debugger-code"; import { CopyClipboardIcon } from "@/components/icons/copy-icon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip } from "@/components/ui/tooltip"; import type { CellId } from "@/core/cells/ids"; import { isInternalCellName } from "@/core/cells/names"; import { useExpandedConsoleOutput } from "@/core/cells/outputs"; import type { WithResponse } from "@/core/cells/types"; import type { OutputMessage } from "@/core/kernel/messages"; import { type UseInputHistoryReturn, useInputHistory, } from "@/hooks/useInputHistory"; import { useOverflowDetection } from "@/hooks/useOverflowDetection"; import { useSelectAllContent } from "@/hooks/useSelectAllContent"; import { cn } from "@/utils/cn"; import { invariant } from "@/utils/invariant"; import { NameCellContentEditable } from "../../actions/name-cell-input"; import { ErrorBoundary } from "../../boundary/ErrorBoundary"; import { type OnRefactorWithAI, OutputRenderer } from "../../Output"; import { useWrapText } from "../useWrapText"; import { processOutput } from "./process-output"; import { RenderTextWithLinks } from "./text-rendering"; /** * Delay in ms before clearing console outputs. * This prevents flickering when a cell re-runs and outputs are briefly cleared * before new outputs arrive (e.g., plt.show() with a slider). */ export const CONSOLE_CLEAR_DEBOUNCE_MS = 200; /** * Debounces the clearing of console outputs. * - Non-empty updates are applied immediately. * - Transitions to empty are delayed by CONSOLE_CLEAR_DEBOUNCE_MS, * giving new outputs a chance to arrive and replace the old ones * without a visible flicker. */ function useDebouncedConsoleOutputs(outputs: T[]): T[] { const [debouncedOutputs, setDebouncedOutputs] = useState(outputs); const timerRef = useRef | null>(null); // Non-empty outputs: apply immediately and cancel any pending clear if (outputs.length > 0 && debouncedOutputs !== outputs) { if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } setDebouncedOutputs(outputs); } // Empty outputs: delay the clear so new outputs can arrive first useEffect(() => { if (outputs.length === 0 && timerRef.current === null) { timerRef.current = setTimeout(() => { timerRef.current = null; setDebouncedOutputs([]); }, CONSOLE_CLEAR_DEBOUNCE_MS); } return () => { if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } }; }, [outputs]); return debouncedOutputs; } interface Props { cellId: CellId; cellName: string; className?: string; consoleOutputs: WithResponse[]; stale: boolean; debuggerActive: boolean; onRefactorWithAI?: OnRefactorWithAI; onClear?: () => void; onSubmitDebugger: (text: string, index: number) => void; } export const ConsoleOutput = (props: Props) => { return ( ); }; const ConsoleOutputInternal = (props: Props): React.ReactNode => { const ref = React.useRef(null); const { wrapText, setWrapText } = useWrapText(); const [isExpanded, setIsExpanded] = useExpandedConsoleOutput(props.cellId); const [stdinValue, setStdinValue] = React.useState(""); const inputHistory = useInputHistory({ value: stdinValue, setValue: setStdinValue, }); const { consoleOutputs: rawConsoleOutputs, stale, cellName, cellId, onSubmitDebugger, onClear, onRefactorWithAI, className, } = props; // Debounce clearing to prevent flickering when cells re-run const consoleOutputs = useDebouncedConsoleOutputs(rawConsoleOutputs); /* The debugger UI needs some work. For now just use the regular /* console output. */ /* if (debuggerActive) { return ( output.data).join("\n")} onSubmit={(text) => onSubmitDebugger(text, consoleOutputs.length - 1)} /> ); } */ const hasOutputs = consoleOutputs.length > 0; // Enable Ctrl/Cmd-A to select all content within the console output const selectAllProps = useSelectAllContent(hasOutputs); // Detect overflow on resize const isOverflowing = useOverflowDetection(ref, hasOutputs); // Keep scroll at the bottom if it is within 120px of the bottom, // so when we add new content, it will lock to the bottom // // We use flex flex-col-reverse to handle this, but it doesn't // always work perfectly when moved form the bottom and back. useLayoutEffect(() => { const el = ref.current; if (!el) { return; } // N.B. This won't handle large jumps in the scroll position // if there is a lot of content added at once. // This is 'good enough' for now. const threshold = 120; const scrollOffset = el.scrollHeight - el.clientHeight; const distanceFromBottom = scrollOffset - el.scrollTop; if (distanceFromBottom < threshold) { el.scrollTop = scrollOffset; } }); if (!hasOutputs && isInternalCellName(cellName)) { return null; } const reversedOutputs = consoleOutputs.toReversed(); const isPdb = reversedOutputs.some( (output) => typeof output.data === "string" && output.data.includes("(Pdb)"), ); // Find the index of the last stdin output since we only want to show // the pdb prompt once const lastStdInputIdx = reversedOutputs.findIndex( (output) => output.channel === "stdin", ); const getOutputString = (): string => { const text = consoleOutputs .filter((output) => output.channel !== "pdb") .map((output) => processOutput(output)) .join("\n"); return text; }; return (
{hasOutputs && (
{(isOverflowing || isExpanded) && ( )}
)}
{reversedOutputs.map((output, idx) => { if (output.channel === "pdb") { return null; } if (output.channel === "stdin") { invariant( typeof output.data === "string", "Expected data to be a string", ); const originalIdx = consoleOutputs.length - idx - 1; const isPassword = output.mimetype === "text/password"; if (output.response == null && lastStdInputIdx === idx) { return ( onSubmitDebugger(text, originalIdx)} onClear={onClear} value={stdinValue} setValue={setStdinValue} inputHistory={inputHistory} /> ); } return ( ); } return ( ); })}
); }; const StdInput = (props: { onSubmit: (text: string) => void; onClear?: () => void; output: string; isPdb: boolean; isPassword?: boolean; value: string; setValue: (value: string) => void; inputHistory: UseInputHistoryReturn; }) => { const { value, setValue, inputHistory, output, isPassword, isPdb, onSubmit, onClear, } = props; const { navigateUp, navigateDown, addToHistory } = inputHistory; return (
{renderText(output)} to find the input data-stdin-blocking={true} type={isPassword ? "password" : "text"} autoComplete="off" autoFocus={true} value={value} onChange={(e) => setValue(e.target.value)} icon={} className="m-0 h-8 focus-visible:shadow-xs-solid" placeholder="stdin" // Capture the keydown event for history navigation and submission onKeyDownCapture={(e) => { if (e.key === "ArrowUp") { navigateUp(); e.preventDefault(); return; } if (e.key === "ArrowDown") { navigateDown(); e.preventDefault(); return; } if (e.key === "Enter" && !e.shiftKey) { if (value) { addToHistory(value); onSubmit(value); setValue(""); } e.preventDefault(); e.stopPropagation(); } // Prevent running the cell if (e.key === "Enter" && e.metaKey) { e.preventDefault(); e.stopPropagation(); } }} /> {isPdb && }
); }; const StdInputWithResponse = (props: { output: string; response?: string; isPassword?: boolean; }) => { return (
{renderText(props.output)} {!props.isPassword && ( {props.response} )}
); }; const renderText = (text: string | null) => { if (!text) { return null; } return ; };