/* Copyright 2026 Marimo. All rights reserved. */ import { useChat } 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 { DefaultChatTransport } from "ai"; 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 } 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 { CELL_COMPLETION_DATA_TYPE, cellCompletionSchema, type CompletionUIMessage, } from "@/core/ai/completion-output"; import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants"; 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 type { AiCompletionRequest } from "@/core/network/types"; 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, AI_EDIT_ACTION_LABELS, createAiCompletionOnKeydown, RejectCompletionButton, } from "./completion-handlers"; import { addContextCompletion, getAICompletionBody } from "./completion-utils"; import { useStagedAICell } from "@/core/ai/staged-cells"; 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 [includeOtherCells, setIncludeOtherCells] = useAtom( includeOtherCellsAtom, ); const includeOtherCellsCheckboxId = useId(); const runtimeManager = useRuntimeManager(); const { initialPrompt, triggerImmediately, cellId: aiCellId, } = aiCompletionCell ?? {}; const enabled = aiCellId === cellId; const updatedCell = useStagedAICell(cellId); let previousCellCode: string | undefined; if (updatedCell?.type === "update_cell") { previousCellCode = updatedCell.previousCode; } const [completion, setCompletion] = useState(""); const [input, setInput] = useState(initialPrompt ?? ""); const transport = React.useMemo( () => new DefaultChatTransport({ api: runtimeManager.getAiURL("completion").toString(), headers: () => runtimeManager.headers(), prepareSendMessagesRequest: ({ body, id }) => ({ body: { ...body, id }, }), }), [runtimeManager], ); const { sendMessage, stop: stopChat, status, } = useChat({ throttle: AI_SDK_UI_THROTTLE_MS, transport, onData: (part) => { if (part.type === CELL_COMPLETION_DATA_TYPE) { const completion = cellCompletionSchema.parse(part.data); setCompletion(completion.code.trimEnd()); } }, onError: (error) => { setCompletion(""); toast({ title: "Completion failed", description: prettyError(error), }); }, onFinish: ({ isAbort, isDisconnect, isError, finishReason }) => { if (isAbort || isDisconnect || isError || finishReason !== "stop") { setCompletion(""); } }, }); const isLoading = status === "submitted" || status === "streaming"; const stop = useCallback(() => { setCompletion(""); void stopChat(); }, [stopChat]); const complete = useCallback( async (prompt: string) => { await stopChat(); setCompletion(""); const body = { ...getAICompletionBody({ input: prompt }), prompt, includeOtherCode: includeOtherCells ? getCodes(currentCode) : "", code: currentCode, language: currentLanguageAdapter ?? "python", } satisfies AiCompletionRequest; await sendMessage({ text: prompt }, { body }); }, [ currentCode, currentLanguageAdapter, includeOtherCells, sendMessage, stopChat, ], ); const handleSubmit = useCallback(() => { void complete(input); }, [complete, input]); const inputRef = React.useRef(null); const initialSubmit = useCallback(() => { if (triggerImmediately && !isLoading && initialPrompt) { // Use complete to pass the prompt directly, else input might be empty void 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(""); }; // Reject discards the suggestion but keeps the prompt open for refinement. const handleDeclineCompletion = () => { stop(); setShowInputPrompt(true); inputRef.current?.view?.focus(); }; 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 && ( <> { stop(); declineChange(); }} value={input} onChange={setInput} 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"}

); };