/* Copyright 2026 Marimo. All rights reserved. */ import { useCompletion } from "@ai-sdk/react"; import { EditorView } from "@codemirror/view"; import { CircleCheckIcon, Loader2Icon, SparklesIcon, XIcon, } from "lucide-react"; import React, { useCallback, useEffect, useId, useState } from "react"; import CodeMirrorMerge from "react-codemirror-merge"; import { Button } from "@/components/ui/button"; import { customPythonLanguageSupport } from "@/core/codemirror/language/languages/python"; import "./merge-editor.css"; import { storePrompt } from "@marimo-team/codemirror-ai"; import type { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { useAtom, useAtomValue } from "jotai"; import { AIModelDropdown } from "@/components/ai/ai-model-dropdown"; import { AddContextButton, SendButton, } from "@/components/chat/chat-components"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { stagedAICellsAtom } from "@/core/ai/staged-cells"; import { type AiCompletionCell, includeOtherCellsAtom } from "@/core/ai/state"; import type { CellId } from "@/core/cells/ids"; import { getCodes } from "@/core/codemirror/copilot/getCodes"; import type { LanguageAdapterType } from "@/core/codemirror/language/types"; import { selectAllText } from "@/core/codemirror/utils"; import { useRuntimeManager } from "@/core/runtime/config"; import { useTheme } from "@/theme/useTheme"; import { cn } from "@/utils/cn"; import { prettyError } from "@/utils/errors"; import { retryWithTimeout } from "@/utils/timeout"; import { PromptInput } from "./add-cell-with-ai"; import { AcceptCompletionButton, createAiCompletionOnKeydown, RejectCompletionButton, } from "./completion-handlers"; import { addContextCompletion, getAICompletionBody } from "./completion-utils"; const Original = CodeMirrorMerge.Original; const Modified = CodeMirrorMerge.Modified; interface Props { cellId: CellId; aiCompletionCell: AiCompletionCell | null; className?: string; currentCode: string; currentLanguageAdapter: LanguageAdapterType | undefined; onChange: (code: string) => void; declineChange: () => void; acceptChange: (rightHandCode: string) => void; runCell: () => void; outputArea?: "above" | "below"; /** * Children shown when there is no completion */ children: React.ReactNode; } const baseExtensions = [customPythonLanguageSupport(), EditorView.lineWrapping]; /** * Editor for AI completions that goes above a cell to modify it. * * This shows a left/right split with the original and modified code. */ export const AiCompletionEditor: React.FC = ({ cellId, aiCompletionCell, className, onChange, currentLanguageAdapter, currentCode, declineChange, acceptChange, runCell, outputArea, children, }) => { const [showInputPrompt, setShowInputPrompt] = useState(false); const [completionBody, setCompletionBody] = useState({}); const [includeOtherCells, setIncludeOtherCells] = useAtom( includeOtherCellsAtom, ); const includeOtherCellsCheckboxId = useId(); const runtimeManager = useRuntimeManager(); const { initialPrompt, triggerImmediately, cellId: aiCellId, } = aiCompletionCell ?? {}; const enabled = aiCellId === cellId; const stagedAICells = useAtomValue(stagedAICellsAtom); const updatedCell = stagedAICells.get(cellId); let previousCellCode: string | undefined; if (updatedCell?.type === "update_cell") { previousCellCode = updatedCell.previousCode; } const { completion: untrimmedCompletion, input, stop, isLoading, setCompletion, setInput, handleSubmit, complete, } = useCompletion({ api: runtimeManager.getAiURL("completion").toString(), headers: runtimeManager.headers(), initialInput: initialPrompt, streamProtocol: "text", // Throttle the messages and data updates to 100ms experimental_throttle: 100, body: { ...(Object.keys(completionBody).length > 0 ? completionBody : initialPrompt ? getAICompletionBody({ input: initialPrompt }) : {}), includeOtherCode: includeOtherCells ? getCodes(currentCode) : "", code: currentCode, language: currentLanguageAdapter, }, onError: (error) => { toast({ title: "Completion failed", description: prettyError(error), }); }, onFinish: (_prompt, completion) => { // Remove trailing new lines setCompletion(completion.trimEnd()); }, }); const inputRef = React.useRef(null); const completion = untrimmedCompletion.trimEnd(); const initialSubmit = useCallback(() => { if (triggerImmediately && !isLoading && initialPrompt) { // Use complete to pass the prompt directly, else input might be empty complete(initialPrompt); } // oxlint-disable-next-line react-hooks/exhaustive-deps }, [triggerImmediately]); // Focus the input useEffect(() => { if (enabled) { retryWithTimeout( () => { const input = inputRef.current; if (input?.view) { input.view.focus(); initialSubmit(); return true; } return false; }, { retries: 3, delay: 100, initialDelay: 100 }, ); // Wait for animation to complete selectAllText(inputRef.current?.view); } }, [enabled, initialSubmit]); // Reset the input when the prompt changes useEffect(() => { if (enabled) { setInput(initialPrompt || ""); } }, [enabled, initialPrompt, setInput]); const { theme } = useTheme(); const handleAcceptCompletion = () => { acceptChange(completion); setCompletion(""); }; const handleDeclineCompletion = () => { declineChange(); setCompletion(""); }; const showCompletionBanner = enabled && triggerImmediately && (completion || isLoading); // Set default output area to below if not specified outputArea = outputArea ?? "below"; const showInput = enabled && (!triggerImmediately || showInputPrompt); const completionBanner = (
); const renderMergeEditor = (originalCode: string, modifiedCode: string) => { return ( ); }; const renderCompletionEditor = () => { if (completion && enabled) { return renderMergeEditor(currentCode, completion); } // If there is no completion and there is previous cell code, it means there is an AI change to the cell. // And we want to render the previous cell code as the original if (!completion && previousCellCode) { return renderMergeEditor(previousCellCode, currentCode); } }; const completionButtons = ( <> ); return (
{enabled && ( <> { declineChange(); setCompletion(""); }} value={input} onChange={(newValue) => { setInput(newValue); setCompletionBody(getAICompletionBody({ input: newValue })); }} onSubmit={() => { if (!isLoading) { if (inputRef.current?.view) { storePrompt(inputRef.current.view); } handleSubmit(); } }} onKeyDown={createAiCompletionOnKeydown({ handleAcceptCompletion, handleDeclineCompletion, isLoading, hasCompletion: completion.trim().length > 0, })} />
addContextCompletion(inputRef)} isLoading={isLoading} />
{completion && (
{completionButtons}
)}
setIncludeOtherCells(Boolean(checked)) } />
)}
{outputArea === "above" && completionBanner} {renderCompletionEditor()} {(!completion || !enabled) && !previousCellCode && children} {/* By default, show the completion banner below the code */} {outputArea === "below" && completionBanner}
); }; interface CompletionBannerProps { status: "loading" | "generated"; onAccept: () => void; onReject: () => void; showInputPrompt: boolean; setShowInputPrompt: (show: boolean) => void; runCell: () => void; className?: string; } const CompletionBanner: React.FC = ({ status, onAccept, onReject, className, showInputPrompt, setShowInputPrompt, runCell, }) => { const isLoading = status === "loading"; return (
{isLoading ? ( ) : ( )}

{isLoading ? "Generating fix..." : "Showing fix"}

); };