import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; import { $applyNodeReplacement, $createRangeSelection, $getSelection, $setSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $createLineBreakNode, $createParagraphNode, $createTextNode, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, $getRoot, DecoratorNode, type ElementNode, type LexicalNode, type SerializedLexicalNode, TextNode, type EditorConfig, type EditorState, type NodeKey, type SerializedTextNode, type Spread, } from "lexical"; import { createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, type ClipboardEventHandler, type ReactElement, type Ref, } from "react"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; import { COMPOSER_INLINE_CHIP_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; type SerializedComposerMentionNode = Spread< { path: string; type: "composer-mention"; version: 1; }, SerializedTextNode >; type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; type: "composer-terminal-context"; version: 1; }, SerializedLexicalNode >; const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: (contextId: string) => void; }>({ onRemoveTerminalContext: () => {}, }); class ComposerMentionNode extends TextNode { __path: string; static override getType(): string { return "composer-mention"; } static override clone(node: ComposerMentionNode): ComposerMentionNode { return new ComposerMentionNode(node.__path, node.__key); } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { return $createComposerMentionNode(serializedNode.path); } constructor(path: string, key?: NodeKey) { const normalizedPath = path.startsWith("@") ? path.slice(1) : path; super(`@${normalizedPath}`, key); this.__path = normalizedPath; } override exportJSON(): SerializedComposerMentionNode { return { ...super.exportJSON(), path: this.__path, type: "composer-mention", version: 1, }; } override createDOM(_config: EditorConfig): HTMLElement { const dom = document.createElement("span"); dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; dom.contentEditable = "false"; dom.setAttribute("spellcheck", "false"); renderMentionChipDom(dom, this.__path); return dom; } override updateDOM( prevNode: ComposerMentionNode, dom: HTMLElement, _config: EditorConfig, ): boolean { dom.contentEditable = "false"; if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { renderMentionChipDom(dom, this.__path); } return false; } override canInsertTextBefore(): false { return false; } override canInsertTextAfter(): false { return false; } override isTextEntity(): true { return true; } override isToken(): true { return true; } } function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } class ComposerTerminalContextNode extends DecoratorNode { __context: TerminalContextDraft; static override getType(): string { return "composer-terminal-context"; } static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { return new ComposerTerminalContextNode(node.__context, node.__key); } static override importJSON( serializedNode: SerializedComposerTerminalContextNode, ): ComposerTerminalContextNode { return $createComposerTerminalContextNode(serializedNode.context); } constructor(context: TerminalContextDraft, key?: NodeKey) { super(key); this.__context = context; } override exportJSON(): SerializedComposerTerminalContextNode { return { ...super.exportJSON(), context: this.__context, type: "composer-terminal-context", version: 1, }; } override createDOM(): HTMLElement { const dom = document.createElement("span"); dom.className = "inline-flex align-middle leading-none"; return dom; } override updateDOM(): false { return false; } override getTextContent(): string { return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; } override isInline(): true { return true; } override decorate(): ReactElement { return ; } } function $createComposerTerminalContextNode( context: TerminalContextDraft, ): ComposerTerminalContextNode { return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode ); } function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } function renderMentionChipDom(container: HTMLElement, pathValue: string): void { container.textContent = ""; container.style.setProperty("user-select", "none"); container.style.setProperty("-webkit-user-select", "none"); const theme = resolvedThemeFromDocument(); const icon = document.createElement("img"); icon.alt = ""; icon.ariaHidden = "true"; icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; icon.loading = "lazy"; icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); const label = document.createElement("span"); label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; label.textContent = basenameOfPath(pathValue); container.append(icon, label); } function terminalContextSignature(contexts: ReadonlyArray): string { return contexts .map((context) => [ context.id, context.threadId, context.terminalId, context.terminalLabel, context.lineStart, context.lineEnd, context.createdAt, context.text, ].join("\u001f"), ) .join("\u001e"); } function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); } function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { return 1; } function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { return node.getTextContentSize(); } function getAbsoluteOffsetForInlineTokenPoint( node: ComposerInlineTokenNode, absoluteOffset: number, pointOffset: number, ): number { return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); } function getExpandedAbsoluteOffsetForInlineTokenPoint( node: ComposerInlineTokenNode, absoluteOffset: number, pointOffset: number, ): number { return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); } function findSelectionPointForInlineToken( node: ComposerInlineTokenNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "element" } | null { const parent = node.getParent(); if (!parent || !$isElementNode(parent)) return null; const index = node.getIndexWithinParent(); if (remainingRef.value === 0) { return { key: parent.getKey(), offset: index, type: "element", }; } if (remainingRef.value === getComposerInlineTokenTextLength(node)) { return { key: parent.getKey(), offset: index + 1, type: "element", }; } remainingRef.value -= getComposerInlineTokenTextLength(node); return null; } function getComposerNodeTextLength(node: LexicalNode): number { if (isComposerInlineTokenNode(node)) { return getComposerInlineTokenTextLength(node); } if ($isTextNode(node)) { return node.getTextContentSize(); } if ($isLineBreakNode(node)) { return 1; } if ($isElementNode(node)) { return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); } return 0; } function getComposerNodeExpandedTextLength(node: LexicalNode): number { if (isComposerInlineTokenNode(node)) { return getComposerInlineTokenExpandedTextLength(node); } if ($isTextNode(node)) { return node.getTextContentSize(); } if ($isLineBreakNode(node)) { return 1; } if ($isElementNode(node)) { return node .getChildren() .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); } return 0; } function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; while (current) { const nextParent = current.getParent() as LexicalNode | null; if (!nextParent || !$isElementNode(nextParent)) { break; } const siblings = nextParent.getChildren(); const index = current.getIndexWithinParent(); for (let i = 0; i < index; i += 1) { const sibling = siblings[i]; if (!sibling) continue; offset += getComposerNodeTextLength(sibling); } current = nextParent; } if ($isTextNode(node)) { if (node instanceof ComposerMentionNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } if (node instanceof ComposerTerminalContextNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); } if ($isElementNode(node)) { const children = node.getChildren(); const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); for (let i = 0; i < clampedOffset; i += 1) { const child = children[i]; if (!child) continue; offset += getComposerNodeTextLength(child); } return offset; } return offset; } function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; while (current) { const nextParent = current.getParent() as LexicalNode | null; if (!nextParent || !$isElementNode(nextParent)) { break; } const siblings = nextParent.getChildren(); const index = current.getIndexWithinParent(); for (let i = 0; i < index; i += 1) { const sibling = siblings[i]; if (!sibling) continue; offset += getComposerNodeExpandedTextLength(sibling); } current = nextParent; } if ($isTextNode(node)) { if (node instanceof ComposerMentionNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } if (node instanceof ComposerTerminalContextNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); } if ($isElementNode(node)) { const children = node.getChildren(); const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); for (let i = 0; i < clampedOffset; i += 1) { const child = children[i]; if (!child) continue; offset += getComposerNodeExpandedTextLength(child); } return offset; } return offset; } function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { if (node instanceof ComposerMentionNode) { return findSelectionPointForInlineToken(node, remainingRef); } if (node instanceof ComposerTerminalContextNode) { return findSelectionPointForInlineToken(node, remainingRef); } if ($isTextNode(node)) { const size = node.getTextContentSize(); if (remainingRef.value <= size) { return { key: node.getKey(), offset: remainingRef.value, type: "text", }; } remainingRef.value -= size; return null; } if ($isLineBreakNode(node)) { const parent = node.getParent(); if (!parent) return null; const index = node.getIndexWithinParent(); if (remainingRef.value === 0) { return { key: parent.getKey(), offset: index, type: "element", }; } if (remainingRef.value === 1) { return { key: parent.getKey(), offset: index + 1, type: "element", }; } remainingRef.value -= 1; return null; } if ($isElementNode(node)) { const children = node.getChildren(); for (const child of children) { const point = findSelectionPointAtOffset(child, remainingRef); if (point) { return point; } } if (remainingRef.value === 0) { return { key: node.getKey(), offset: children.length, type: "element", }; } } return null; } function $getComposerRootLength(): number { const root = $getRoot(); const children = root.getChildren(); return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); } function $setSelectionAtComposerOffset(nextOffset: number): void { const root = $getRoot(); const composerLength = $getComposerRootLength(); const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); const remainingRef = { value: boundedOffset }; const point = findSelectionPointAtOffset(root, remainingRef) ?? { key: root.getKey(), offset: root.getChildren().length, type: "element" as const, }; const selection = $createRangeSelection(); selection.anchor.set(point.key, point.offset, point.type); selection.focus.set(point.key, point.offset, point.type); $setSelection(selection); } function $readSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } const anchorNode = selection.anchor.getNode(); const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); const composerLength = $getComposerRootLength(); return Math.max(0, Math.min(offset, composerLength)); } function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } const anchorNode = selection.anchor.getNode(); const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); const expandedLength = $getRoot().getTextContent().length; return Math.max(0, Math.min(offset, expandedLength)); } function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; if (line.length > 0) { parent.append($createTextNode(line)); } if (index < lines.length - 1) { parent.append($createLineBreakNode()); } } } function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); continue; } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); } continue; } $appendTextWithLineBreaks(paragraph, segment.text); } } function collectTerminalContextIds(node: LexicalNode): string[] { if (node instanceof ComposerTerminalContextNode) { return [node.__context.id]; } if ($isElementNode(node)) { return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); } return []; } export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; readSnapshot: () => { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; }; } interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; onPaste: ClipboardEventHandler; } interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps { editorRef: Ref; } function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; }) { const [editor] = useLexicalComposerContext(); useEffect(() => { const handleCommand = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent | null, ): boolean => { if (!props.onCommandKeyDown || !event) { return false; } const handled = props.onCommandKeyDown(key, event); if (handled) { event.preventDefault(); event.stopPropagation(); } return handled; }; const unregisterArrowDown = editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => handleCommand("ArrowDown", event), COMMAND_PRIORITY_HIGH, ); const unregisterArrowUp = editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => handleCommand("ArrowUp", event), COMMAND_PRIORITY_HIGH, ); const unregisterEnter = editor.registerCommand( KEY_ENTER_COMMAND, (event) => handleCommand("Enter", event), COMMAND_PRIORITY_HIGH, ); const unregisterTab = editor.registerCommand( KEY_TAB_COMMAND, (event) => handleCommand("Tab", event), COMMAND_PRIORITY_HIGH, ); return () => { unregisterArrowDown(); unregisterArrowUp(); unregisterEnter(); unregisterTab(); }; }, [editor, props]); return null; } function ComposerInlineTokenArrowPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { const unregisterLeft = editor.registerCommand( KEY_ARROW_LEFT_COMMAND, (event) => { let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { return; } nextOffset = currentOffset - 1; }); if (nextOffset === null) return false; const selectionOffset = nextOffset; event?.preventDefault(); event?.stopPropagation(); editor.update(() => { $setSelectionAtComposerOffset(selectionOffset); }); return true; }, COMMAND_PRIORITY_HIGH, ); const unregisterRight = editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, (event) => { let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const currentOffset = $readSelectionOffsetFromEditorState(0); const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { return; } nextOffset = currentOffset + 1; }); if (nextOffset === null) return false; const selectionOffset = nextOffset; event?.preventDefault(); event?.stopPropagation(); editor.update(() => { $setSelectionAtComposerOffset(selectionOffset); }); return true; }, COMMAND_PRIORITY_HIGH, ); return () => { unregisterLeft(); unregisterRight(); }; }, [editor]); return null; } function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { let afterOffset: number | null = null; editorState.read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const anchorNode = selection.anchor.getNode(); if (!isComposerInlineTokenNode(anchorNode)) return; if (selection.anchor.offset === 0) return; const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); afterOffset = beforeOffset + 1; }); if (afterOffset !== null) { queueMicrotask(() => { editor.update(() => { $setSelectionAtComposerOffset(afterOffset!); }); }); } }); }, [editor]); return null; } function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( KEY_BACKSPACE_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return false; } const anchorNode = selection.anchor.getNode(); const selectionOffset = $readSelectionOffsetFromEditorState(0); const removeInlineTokenNode = (candidate: unknown): boolean => { if (!isComposerInlineTokenNode(candidate)) { return false; } const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); candidate.remove(); if (candidate instanceof ComposerTerminalContextNode) { onRemoveTerminalContext(candidate.__context.id); $setSelectionAtComposerOffset(selectionOffset); } else { $setSelectionAtComposerOffset(tokenStart); } event?.preventDefault(); return true; }; if (removeInlineTokenNode(anchorNode)) { return true; } if ($isTextNode(anchorNode)) { if (selection.anchor.offset > 0) { return false; } if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { return true; } const parent = anchorNode.getParent(); if ($isElementNode(parent)) { const index = anchorNode.getIndexWithinParent(); if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { return true; } } return false; } if ($isElementNode(anchorNode)) { const childIndex = selection.anchor.offset - 1; if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { return true; } } return false; }, COMMAND_PRIORITY_HIGH, ); }, [editor, onRemoveTerminalContext]); return null; } function ComposerPromptEditorInner({ value, cursor, terminalContexts, disabled, placeholder, className, onRemoveTerminalContext, onChange, onCommandKeyDown, onPaste, editorRef, }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const snapshotRef = useRef({ value, cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), [onRemoveTerminalContext], ); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && !contextsChanged ) { return; } snapshotRef.current = { value, cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); if (previousSnapshot.value === value && !contextsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); } }); queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, false, snapshotRef.current.terminalContextIds, ); }, [editor], ); const readSnapshot = useCallback((): { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, snapshotRef.current.expandedCursor, ); const nextExpandedCursor = clampExpandedCursor( nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, }; }); snapshotRef.current = snapshot; return snapshot; }, [editor]); useImperativeHandle( editorRef, () => ({ focus: () => { focusAt(snapshotRef.current.cursor); }, focusAt, focusAtEnd: () => { focusAt( collapseExpandedComposerCursor( snapshotRef.current.value, snapshotRef.current.value.length, ), ); }, readSnapshot, }), [focusAt, readSnapshot], ); const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, snapshotRef.current.expandedCursor, ); const nextExpandedCursor = clampExpandedCursor( nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ) { return; } if (isApplyingControlledUpdateRef.current) { return; } snapshotRef.current = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); onChangeRef.current( nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention, terminalContextIds, ); }); }, []); return (
} onPaste={onPaste} /> } placeholder={ terminalContexts.length > 0 ? null : (
{placeholder}
) } ErrorBoundary={LexicalErrorBoundary} />
); } export const ComposerPromptEditor = forwardRef< ComposerPromptEditorHandle, ComposerPromptEditorProps >(function ComposerPromptEditor( { value, cursor, terminalContexts, disabled, placeholder, className, onRemoveTerminalContext, onChange, onCommandKeyDown, onPaste, }, ref, ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, nodes: [ComposerMentionNode, ComposerTerminalContextNode], editorState: () => { $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); }, onError: (error) => { throw error; }, }), [], ); return ( ); });