/* Copyright 2026 Marimo. All rights reserved. */ import { closeCompletion, completionStatus } from "@codemirror/autocomplete"; import type { EditorView } from "@codemirror/view"; import clsx from "clsx"; import { useAtomValue, useSetAtom } from "jotai"; import { HelpCircleIcon, MoreHorizontalIcon, SquareFunctionIcon, } from "lucide-react"; import { type FocusEvent, forwardRef, type KeyboardEvent, memo, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { mergeProps } from "react-aria"; import useEvent from "react-use-event-hook"; import { StopButton } from "@/components/editor/cell/StopButton"; import { Toolbar, ToolbarItem } from "@/components/editor/cell/toolbar"; import { Tooltip, TooltipProvider } from "@/components/ui/tooltip"; import { aiCompletionCellAtom } from "@/core/ai/state"; import { outputIsLoading, outputIsStale } from "@/core/cells/cell"; import { isOutputEmpty } from "@/core/cells/outputs"; import { autocompletionKeymap } from "@/core/codemirror/cm"; import type { LanguageAdapterType } from "@/core/codemirror/language/types"; import { CSSClasses } from "@/core/constants"; import { canCollapseOutline } from "@/core/dom/outline"; import { isErrorMime } from "@/core/mime"; import type { AppMode } from "@/core/mode"; import { connectionAtom } from "@/core/network/connection"; import { useRequestClient } from "@/core/network/requests"; import type { CellConfig, RuntimeState } from "@/core/network/types"; import { useResizeObserver } from "@/hooks/useResizeObserver"; import { cn } from "@/utils/cn"; import type { Milliseconds, Seconds } from "@/utils/time"; import { type CellActions, createUntouchedCellAtom, useCellActions, useCellData, useCellHandle, useCellRuntime, } from "../../core/cells/cells"; import { type CellId, SETUP_CELL_ID } from "../../core/cells/ids"; import { isUninstantiated } from "../../core/cells/utils"; import type { UserConfig } from "../../core/config/config-schema"; import { isAppInteractionDisabled } from "../../core/websocket/connection-utils"; import { useCellRenderCount } from "../../hooks/useCellRenderCount"; import type { Theme } from "../../theme/useTheme"; import { derefNotNull } from "../../utils/dereference"; import { Functions } from "../../utils/functions"; import { Logger } from "../../utils/Logger"; import { renderShortcut } from "../shortcuts/renderShortcut"; import { CellStatusComponent } from "./cell/CellStatus"; import { CreateCellButton } from "./cell/CreateCellButton"; import { CellActionsDropdown, type CellActionsDropdownHandle, } from "./cell/cell-actions"; import { CellActionsContextMenu } from "./cell/cell-context-menu"; import { CellEditor } from "./cell/code/cell-editor"; import { CollapsedCellBanner, CollapseToggle } from "./cell/collapse"; import { DeleteButton } from "./cell/DeleteButton"; import { PendingDeleteConfirmation } from "./cell/PendingDeleteConfirmation"; import { RunButton } from "./cell/RunButton"; import { StagedAICellBackground, StagedAICellFooter, } from "./cell/StagedAICell"; import { useDeleteCellCallback } from "./cell/useDeleteCell"; import { useRunCell } from "./cell/useRunCells"; import { cellDomProps } from "./common"; import { SqlValidationErrorBanner } from "./errors/sql-validation-errors"; import { useCellNavigationProps } from "./navigation/navigation"; import { useTemporarilyShownCode, useTemporarilyShownCodeActions, } from "./navigation/state"; import { type OnRefactorWithAI, OutputArea } from "./Output"; import { ConsoleOutput } from "./output/console/ConsoleOutput"; import { CellDragHandle, SortableCell } from "./SortableCell"; /** * Hook for handling cell completion logic */ function useCellCompletion( cellRef: React.RefObject, editorView: React.RefObject, ) { // Close completion when focus leaves the cell's subtree. const closeCompletionHandler = useEvent((e: FocusEvent) => { if ( cellRef.current !== null && !cellRef.current.contains(e.relatedTarget) && editorView.current !== null ) { closeCompletion(editorView.current); } }); // Clicking on the completion info causes the editor view to lose focus, // because the completion is not a child of the editable editor DOM; // as a workaround, when a completion is active, we refocus the editor // on any keypress. // // See https://discuss.codemirror.net/t/adding-click-event-listener-to-autocomplete-tooltip-info-panel-is-not-working/4741 const resumeCompletionHandler = useEvent((e: KeyboardEvent) => { if ( cellRef.current !== document.activeElement || editorView.current === null || completionStatus(editorView.current.state) !== "active" ) { return; } for (const keymap of autocompletionKeymap) { if (e.key === keymap.key && keymap.run) { keymap.run(editorView.current); // preventDefault/stopPropagation: Don't process the keystrokes as // typing into the editor, e.g., Enter should only select the // completion, not also add a newline. e.preventDefault(); e.stopPropagation(); break; } } editorView.current.focus(); return; }); return { closeCompletionHandler, resumeCompletionHandler, }; } /** * Hook for handling hidden cell logic. * * The code is shown if: * - hide_code is false * - the cell-editor is focused (temporarily shown) * - the cell is newly created (untouched) */ function useCellHiddenLogic({ cellId, cellConfig, languageAdapter, editorView, }: { cellId: CellId; cellConfig: CellConfig; languageAdapter: LanguageAdapterType | undefined; editorView: React.RefObject; editorViewParentRef: React.RefObject; }) { const temporarilyVisible = useTemporarilyShownCode(cellId); const temporarilyShownCodeActions = useTemporarilyShownCodeActions(); const isUntouched = useAtomValue( useMemo(() => createUntouchedCellAtom(cellId), [cellId]), ); // The cell code is shown if the cell is not configured to be hidden or if the code is temporarily visible (i.e. when focused). const isCellCodeShown = !cellConfig.hide_code || temporarilyVisible || isUntouched; const isMarkdown = languageAdapter === "markdown"; const isMarkdownCodeHidden = isMarkdown && !isCellCodeShown; // Callback to show the code editor temporarily const showHiddenCode = useEvent((opts?: { focus?: boolean }) => { // Already shown, do nothing if (isCellCodeShown) { return; } // Default to true const focus = opts?.focus ?? true; temporarilyShownCodeActions.add(cellId); if (focus) { editorView.current?.focus(); } // Undoing happens in editor/focus/focus.ts, when the cell is blurred. }); const showHiddenCodeIfMarkdown = useEvent(() => { if (isMarkdownCodeHidden) { showHiddenCode({ focus: true }); } }); return { isCellCodeShown, isMarkdown, isMarkdownCodeHidden, showHiddenCode, showHiddenCodeIfMarkdown, }; } export type CellComponentActions = Pick< CellActions, | "updateCellCode" | "createNewCell" | "focusCell" | "moveCell" | "collapseCell" | "expandCell" | "moveToNextCell" | "updateCellConfig" | "clearSerializedEditorState" | "setStdinResponse" | "clearCellConsoleOutput" | "sendToBottom" | "sendToTop" >; /** * Imperative interface of the cell. */ export interface CellHandle { /** * The CodeMirror editor view. */ editorView: EditorView; /** * The CodeMirror editor view, or null if it is not yet mounted. */ editorViewOrNull: EditorView | null; } export interface CellProps { cellId: CellId; theme: Theme; showPlaceholder: boolean; mode: AppMode; /** * False only when there is only one cell in the notebook. */ canDelete: boolean; userConfig: UserConfig; /** * If true, the cell is allowed to be moved left and right. */ canMoveX: boolean; /** * If true, the cell is collapsed. */ isCollapsed: boolean; /** * The number of cells in the column. */ collapseCount: number; } const CellComponent = (props: CellProps) => { const { cellId, mode } = props; const ref = useCellHandle(cellId); useCellRenderCount().countRender(); Logger.debug("Rendering Cell", cellId); const editorView = useRef(null); // An imperative interface to the code editor useImperativeHandle( ref, () => ({ get editorView() { return derefNotNull(editorView); }, get editorViewOrNull() { return editorView.current; }, }), [editorView], ); if (cellId === SETUP_CELL_ID) { return ( { editorView.current = ev; }} /> ); } if (mode === "edit") { return ( { editorView.current = ev; }} /> ); } return ; }; const ReadonlyCellComponent = forwardRef( (props: { cellId: CellId }, ref: React.ForwardedRef) => { const { cellId } = props; const cellData = useCellData(cellId); const cellRuntime = useCellRuntime(cellId); const className = clsx("marimo-cell", "hover-actions-parent z-10", { published: true, }); const outputIsError = isErrorMime(cellRuntime.output?.mimetype); // Hide the output if it's an error or stopped. const hidden = cellRuntime.errored || cellRuntime.interrupted || cellRuntime.stopped || outputIsError; if (hidden) { return null; } return (
); }, ); ReadonlyCellComponent.displayName = "ReadonlyCellComponent"; const EditableCellComponent = ({ theme, showPlaceholder, cellId, canDelete, userConfig, isCollapsed, collapseCount, canMoveX, editorView, setEditorView, }: CellProps & { editorView: React.RefObject; setEditorView: (view: EditorView) => void; }) => { const cellRef = useRef(null); const cellData = useCellData(cellId); const cellRuntime = useCellRuntime(cellId); const cellActionDropdownRef = useRef(null); // DOM node where the editorView will be mounted const editorViewParentRef = useRef(null); const cellContainerRef = useRef(null); const actions = useCellActions(); const connection = useAtomValue(connectionAtom); const setAiCompletionCell = useSetAtom(aiCompletionCellAtom); const deleteCell = useDeleteCellCallback(); const runCell = useRunCell(cellId); const { sendStdin } = useRequestClient(); const [languageAdapter, setLanguageAdapter] = useState(); const disabledOrAncestorDisabled = cellData.config.disabled || cellRuntime.status === "disabled-transitively"; const uninstantiated = isUninstantiated({ executionTime: cellRuntime.runElapsedTimeMs ?? cellData.lastExecutionTime, status: cellRuntime.status, errored: cellRuntime.errored, interrupted: cellRuntime.interrupted, stopped: cellRuntime.stopped, }); const needsRun = cellData.edited || cellRuntime.interrupted || (cellRuntime.staleInputs && !disabledOrAncestorDisabled); const loading = outputIsLoading(cellRuntime.status); // console output is cleared immediately on run, so check for queued instead // of loading to determine staleness const consoleOutputStale = (cellRuntime.status === "queued" || cellData.edited || cellRuntime.staleInputs) && !cellRuntime.interrupted; // Callback to get the editor view. const getEditorView = useCallback(() => editorView.current, [editorView]); // Use the extracted hooks const { closeCompletionHandler, resumeCompletionHandler } = useCellCompletion( cellRef, editorView, ); const { isCellCodeShown, isMarkdown, isMarkdownCodeHidden, showHiddenCode, showHiddenCodeIfMarkdown, } = useCellHiddenLogic({ cellId, cellConfig: cellData.config, languageAdapter, editorView, editorViewParentRef, }); // Hotkey and focus props const navigationProps = useCellNavigationProps(cellId, { canMoveX, editorView, cellActionDropdownRef, }); const canCollapse = canCollapseOutline(cellRuntime.outline); const hasOutput = !isOutputEmpty(cellRuntime.output); const isStaleCell = outputIsStale(cellRuntime, cellData.edited); const hasConsoleOutput = cellRuntime.consoleOutputs.length > 0; const cellOutput = userConfig.display.cell_output; const hasOutputAbove = hasOutput && cellOutput === "above"; // If the cell is too short, we need to position some icons inline to prevent overlaps. // This can only happen to markdown cells when the code is hidden completely const [isCellStatusInline, setIsCellStatusInline] = useState(false); const [isCellButtonsInline, setIsCellButtonsInline] = useState(false); // For markdown cells, get the inner content directly from the editor // (editorView.state.doc contains the transformed markdown, not the mo.md(...) wrapper) const isEmptyMarkdownContent = isMarkdown && editorView.current?.state.doc.toString().trim() === ""; useResizeObserver({ ref: cellContainerRef, skip: !isMarkdown, onResize: (size) => { const cellTooShort = size.height && size.height < 68; const shouldBeInline = isMarkdownCodeHidden && (cellTooShort || cellOutput === "below"); setIsCellStatusInline(shouldBeInline); if (canCollapse && shouldBeInline) { setIsCellButtonsInline(true); } else if (isCellButtonsInline) { setIsCellButtonsInline(false); } }, }); const emptyMarkdownPlaceholder = isMarkdownCodeHidden && isEmptyMarkdownContent && !needsRun && (
{ if (e.key === "Enter") { showHiddenCodeIfMarkdown(); } }} tabIndex={0} > Double-click (or enter) to edit
); const outputArea = hasOutput && !isEmptyMarkdownContent && (
{ if (isCollapsed) { actions.expandCell({ cellId }); } else { actions.collapseCell({ cellId }); } }} canCollapse={canCollapse} />
); const className = clsx("marimo-cell", "hover-actions-parent z-10", { interactive: true, "needs-run": needsRun, "has-error": cellRuntime.errored, stopped: cellRuntime.stopped, disabled: cellData.config.disabled, stale: cellRuntime.status === "disabled-transitively", borderless: isMarkdownCodeHidden && hasOutput && !navigationProps["data-selected"], }); const handleRefactorWithAI: OnRefactorWithAI = useEvent( (opts: { prompt: string; triggerImmediately: boolean }) => { setAiCompletionCell({ cellId, initialPrompt: opts.prompt, triggerImmediately: opts.triggerImmediately, }); }, ); // TODO(akshayka): Move to our own Tooltip component once it's easier // to get the tooltip to show next to the cursor ... // https://github.com/radix-ui/primitives/discussions/1090 const renderCellTitle = () => { if (cellData.config.disabled) { return "This cell is disabled"; } if (cellRuntime.status === "disabled-transitively") { return "This cell has a disabled ancestor"; } return undefined; }; const isToplevel = cellRuntime.serialization?.toLowerCase() === "valid"; return (
{cellOutput === "above" && (outputArea || emptyMarkdownPlaceholder)}
{cellOutput === "below" && (outputArea || emptyMarkdownPlaceholder)} {cellRuntime.serialization && (
{isToplevel && ( reusable )} {(isToplevel && "This function or class can be imported into other Python notebooks or modules.") || ( <> This definition can't be reused in other Python modules:

{cellRuntime.serialization}

Click this icon to learn more. )} } > {isToplevel ? ( ) : ( )}
)} { actions.clearCellConsoleOutput({ cellId }); }} onSubmitDebugger={(text, index) => { actions.setStdinResponse({ cellId, response: text, outputIndex: index, }); sendStdin({ text }); }} cellId={cellId} debuggerActive={cellRuntime.debuggerActive} />
{isCollapsed && ( actions.expandCell({ cellId })} count={collapseCount} cellId={cellId} /> )}
); }; const CellRightSideActions = memo( (props: { className?: string; disabled: boolean | undefined; edited: boolean; interrupted: boolean; isCellStatusInline: boolean; lastRunStartTimestamp: Seconds | null; runElapsedTimeMs: Milliseconds | null; runStartTimestamp: Seconds | null; staleInputs: boolean; status: RuntimeState; uninstantiated: boolean; }) => { const { className, disabled = false, edited, interrupted, isCellStatusInline, lastRunStartTimestamp, runElapsedTimeMs, runStartTimestamp, staleInputs, status, uninstantiated, } = props; const cellStatusComponent = ( ); return (
{!isCellStatusInline && cellStatusComponent}
{isCellStatusInline && cellStatusComponent}
); }, ); CellRightSideActions.displayName = "CellRightSideActions"; const CellLeftSideActions = memo( (props: { className?: string; cellId: CellId; actions: CellComponentActions; }) => { const connection = useAtomValue(connectionAtom); const { className, actions, cellId } = props; const createBelow = useEvent( (opts: { code?: string; hideCode?: boolean } = {}) => actions.createNewCell({ cellId, before: false, ...opts }), ); const createAbove = useEvent( (opts: { code?: string; hideCode?: boolean } = {}) => actions.createNewCell({ cellId, before: true, ...opts }), ); const oneClickShortcut = "mod"; return (
{/*
*/}
); }, ); CellLeftSideActions.displayName = "CellLeftSideActions"; interface CellToolbarProps { edited: boolean; status: RuntimeState; cellConfig: CellConfig; needsRun: boolean; hasOutput: boolean; hasConsoleOutput: boolean; cellActionDropdownRef: React.RefObject; cellId: CellId; name: string; includeCellActions?: boolean; getEditorView: () => EditorView | null; onRun: () => void; } const CellToolbar = memo( ({ edited, status, cellConfig, needsRun, hasOutput, hasConsoleOutput, onRun, cellActionDropdownRef, cellId, getEditorView, name, includeCellActions = true, }: CellToolbarProps) => { const connection = useAtomValue(connectionAtom); return ( {includeCellActions && ( )} ); }, ); CellToolbar.displayName = "CellToolbar"; /** * A cell that is not allowed to be deleted or moved. * It also has no outputs. */ const SetupCellComponent = ({ theme, showPlaceholder, cellId, canDelete, userConfig, canMoveX, editorView, setEditorView, }: CellProps & { editorView: React.RefObject; setEditorView: (view: EditorView) => void; }) => { const cellRef = useRef(null); const cellData = useCellData(cellId); const cellRuntime = useCellRuntime(cellId); const cellActionDropdownRef = useRef(null); // DOM node where the editorView will be mounted const editorViewParentRef = useRef(null); const connection = useAtomValue(connectionAtom); const actions = useCellActions(); const requestClient = useRequestClient(); const deleteCell = useDeleteCellCallback(); const setAiCompletionCell = useSetAtom(aiCompletionCellAtom); const runCell = useRunCell(cellId); const disabledOrAncestorDisabled = cellData.config.disabled || cellRuntime.status === "disabled-transitively"; const uninstantiated = isUninstantiated({ executionTime: cellRuntime.runElapsedTimeMs ?? cellData.lastExecutionTime, status: cellRuntime.status, errored: cellRuntime.errored, interrupted: cellRuntime.interrupted, stopped: cellRuntime.stopped, }); const needsRun = cellData.edited || cellRuntime.interrupted || (cellRuntime.staleInputs && !disabledOrAncestorDisabled); const loading = cellRuntime.status === "running" || cellRuntime.status === "queued"; // console output is cleared immediately on run, so check for queued instead // of loading to determine staleness const consoleOutputStale = (cellRuntime.status === "queued" || cellData.edited || cellRuntime.staleInputs) && !cellRuntime.interrupted; // Callback to get the editor view. const getEditorView = useCallback(() => editorView.current, [editorView]); const { isCellCodeShown, showHiddenCode } = useCellHiddenLogic({ cellId, cellConfig: cellData.config, languageAdapter: "python", editorView, editorViewParentRef, }); // Use the extracted hooks const { closeCompletionHandler, resumeCompletionHandler } = useCellCompletion( cellRef, editorView, ); // Hotkeys and focus props const navigationProps = useCellNavigationProps(cellId, { canMoveX, editorView, cellActionDropdownRef, }); const hasOutput = !isOutputEmpty(cellRuntime.output); const hasConsoleOutput = cellRuntime.consoleOutputs.length > 0; const isErrorOutput = isErrorMime(cellRuntime.output?.mimetype); const className = clsx("marimo-cell", "hover-actions-parent z-10", { interactive: true, "needs-run": needsRun, "has-error": cellRuntime.errored, stopped: cellRuntime.stopped, }); const handleRefactorWithAI: OnRefactorWithAI = useEvent( (opts: { prompt: string; triggerImmediately: boolean }) => { setAiCompletionCell({ cellId, initialPrompt: opts.prompt, triggerImmediately: opts.triggerImmediately, }); }, ); // TODO(akshayka): Move to our own Tooltip component once it's easier // to get the tooltip to show next to the cursor ... // https://github.com/radix-ui/primitives/discussions/1090 const renderCellTitle = () => { if (cellData.config.disabled) { return "This cell is disabled"; } if (cellRuntime.status === "disabled-transitively") { return "This cell has a disabled ancestor"; } return undefined; }; return (
setup cell This setup cell is guaranteed to run before all other cells. Include
initialization or imports and constants required by top-level functions. } >
{isErrorOutput && ( )} { actions.clearCellConsoleOutput({ cellId }); }} onSubmitDebugger={(text, index) => { actions.setStdinResponse({ cellId, response: text, outputIndex: index, }); requestClient.sendStdin({ text }); }} cellId={cellId} debuggerActive={cellRuntime.debuggerActive} />
); }; export const Cell = memo(CellComponent);