Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

775 lines
26 KiB
TypeScript

import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent
} from "react";
import type { Editor } from "@tiptap/core";
import { useEditor } from "@tiptap/react";
import { cn } from "../../../app/renderer/lib/utils";
import { useTranslation } from "../../../i18n/index";
import { createAgentRichTextInputExtensions } from "./agentRichTextExtensions";
import {
agentRichTextContentToPromptText,
editorToPromptText,
plainTextToAgentRichTextInlineContent,
plainTextToAgentRichTextDoc
} from "./agentRichTextDocument";
import { isAgentRichTextImeComposing } from "./agentRichTextIme";
import {
hasWorkspaceFileDropData,
readWorkspaceFileDropEntries
} from "../../terminalNode/workspaceFileDrop";
import {
readAgentRichTextPromptImages,
routeAgentRichTextExternalFiles,
systemFileDragInfoFromDataTransfer
} from "./agentRichTextPromptImages";
import type {
AgentRichTextContextMenuState,
AgentRichTextEditorHandle,
AgentRichTextEditorProps
} from "./AgentRichTextEditor.types";
import {
buildWorkspaceFileMentionDropContent,
createAgentRichTextCaretAnchorExtension,
createAgentRichTextPlaceholderExtension,
isAgentRichTextLargeTextPaste,
isPromptVisualLineStart,
readEditorDomSelectionRange,
readPlainTextFromClipboard,
readPromptSelection,
readPromptTextRange,
readSelectedPlainText,
serializeAgentRichTextSelection,
scrollEditorSelectionIntoView,
writePlainTextToClipboard
} from "./agentRichTextEditorSupport";
export { isAgentRichTextLargeTextPaste } from "./agentRichTextEditorSupport";
import { routeAgentRichTextTextPaste } from "./routeAgentRichTextTextPaste";
import { useAgentRichTextEditorHandle } from "./useAgentRichTextEditorHandle";
import { AgentRichTextEditorSurface } from "./AgentRichTextEditorSurface";
import { handleAgentRichTextKeyDownCapture } from "./agentRichTextKeyboard";
import {
isAgentRichTextUserContentInsertion,
markAgentRichTextPointerFocus
} from "./agentRichTextEngagement";
import {
createAgentRichTextControlledValueTracker,
recordAgentRichTextLocalEdit,
resolveAgentRichTextControlledSelection,
shouldApplyAgentRichTextControlledValue
} from "./agentRichTextControlledValue";
import { createAgentRichTextMentionSuggestionSuppression } from "./agentRichTextMentionSuggestionSuppression";
export type {
AgentRichTextEditorHandle,
AgentRichTextEditorProps,
AgentRichTextPastedImage
} from "./AgentRichTextEditor.types";
export const AgentRichTextEditor = forwardRef<
AgentRichTextEditorHandle,
AgentRichTextEditorProps
>(function AgentRichTextEditor(
{
value,
contentScopeKey = "default",
disabled,
placeholder,
removeMentionLabel,
className,
testId,
onChange,
onContentLayoutInvalidated,
onFocus,
onUserContentChange,
onSubmit,
onSubmitGuidance,
availableCapabilities = [],
availableSkills = [],
submitOnEnter = true,
enableFileMentionSuggestions = true,
onKeyDownForPalette,
onHistoryNavigation,
onFileMentionSuggestionChange,
onFileMentionSuggestionKeyDown,
onLinkClick,
promptImagesSupported = true,
onPromptImagesUnsupported,
onPasteImages,
onPasteLargeText,
onPasteFiles,
onDropFiles,
onResolvePastedPath
},
ref
): React.JSX.Element {
"use memo";
const { t } = useTranslation();
const lastEmittedPromptRef = useRef<string | null>(value);
const controlledValueTrackerRef = useRef(
createAgentRichTextControlledValueTracker(contentScopeKey)
);
const appliedContentScopeKeyRef = useRef(contentScopeKey);
const editorRef = useRef<Editor | null>(null);
const onChangeRef = useRef(onChange);
const onContentLayoutInvalidatedRef = useRef(onContentLayoutInvalidated);
const onFocusRef = useRef(onFocus);
const onUserContentChangeRef = useRef(onUserContentChange);
const pendingFocusMethodRef = useRef<"pointer" | "programmatic" | null>(null);
const onSubmitRef = useRef(onSubmit);
const onSubmitGuidanceRef = useRef(onSubmitGuidance);
const onKeyDownForPaletteRef = useRef(onKeyDownForPalette);
const onHistoryNavigationRef = useRef(onHistoryNavigation);
const onFileMentionSuggestionChangeRef = useRef(
onFileMentionSuggestionChange
);
const onFileMentionSuggestionKeyDownRef = useRef(
onFileMentionSuggestionKeyDown
);
const onLinkClickRef = useRef(onLinkClick);
const onPromptImagesUnsupportedRef = useRef(onPromptImagesUnsupported);
const onPasteImagesRef = useRef(onPasteImages);
const onPasteLargeTextRef = useRef(onPasteLargeText);
const onPasteFilesRef = useRef(onPasteFiles);
const onDropFilesRef = useRef(onDropFiles);
const onResolvePastedPathRef = useRef(onResolvePastedPath);
const promptImagesSupportedRef = useRef(promptImagesSupported);
const placeholderRef = useRef(placeholder);
const removeMentionLabelRef = useRef(removeMentionLabel);
const availableSkillsRef = useRef(availableSkills);
const availableCapabilitiesRef = useRef(availableCapabilities);
const [mentionSuggestionSuppression] = useState(() =>
createAgentRichTextMentionSuggestionSuppression(value)
);
const scrollFrameRef = useRef<number | null>(null);
const [contextMenu, setContextMenu] =
useState<AgentRichTextContextMenuState | null>(null);
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
const insertPlainText = useCallback((text: string): void => {
const currentEditor = editorRef.current;
if (!currentEditor || currentEditor.isDestroyed || !text) {
return;
}
if (onPasteLargeTextRef.current && isAgentRichTextLargeTextPaste(text)) {
onPasteLargeTextRef.current(text);
return;
}
mentionSuggestionSuppression.suppressTextInsertion(text);
currentEditor
.chain()
.focus()
.insertContent(
plainTextToAgentRichTextInlineContent(text, {
capabilities: availableCapabilitiesRef.current,
skills: availableSkillsRef.current
})
)
.run();
}, []);
const copySelection = useCallback(async (): Promise<void> => {
const currentEditor = editorRef.current;
const selectedText =
contextMenu && contextMenu.hasSelection && currentEditor
? readPromptTextRange(
currentEditor,
contextMenu.selectionFrom,
contextMenu.selectionTo
)
: currentEditor
? readSelectedPlainText(currentEditor)
: "";
closeContextMenu();
if (!currentEditor || currentEditor.isDestroyed) {
return;
}
await writePlainTextToClipboard(selectedText);
}, [closeContextMenu, contextMenu]);
const cutSelection = useCallback(async (): Promise<void> => {
const currentEditor = editorRef.current;
const selectionFrom = contextMenu?.selectionFrom ?? null;
const selectionTo = contextMenu?.selectionTo ?? null;
const selectedText =
contextMenu && contextMenu.hasSelection && currentEditor
? readPromptTextRange(
currentEditor,
contextMenu.selectionFrom,
contextMenu.selectionTo
)
: currentEditor
? readSelectedPlainText(currentEditor)
: "";
closeContextMenu();
if (!currentEditor || currentEditor.isDestroyed || disabled) {
return;
}
if (!(await writePlainTextToClipboard(selectedText))) {
return;
}
const { from, to } =
selectionFrom !== null &&
selectionTo !== null &&
selectionFrom < selectionTo
? { from: selectionFrom, to: selectionTo }
: currentEditor.state.selection;
currentEditor.chain().focus().deleteRange({ from, to }).run();
}, [closeContextMenu, contextMenu, disabled]);
const pasteClipboardText = useCallback(async (): Promise<void> => {
closeContextMenu();
if (disabled) {
return;
}
const text = await readPlainTextFromClipboard();
if (text) {
insertPlainText(text);
}
}, [closeContextMenu, disabled, insertPlainText]);
const scheduleSelectionScroll = (targetEditor: Editor): void => {
if (typeof window.requestAnimationFrame !== "function") {
scrollEditorSelectionIntoView(targetEditor);
return;
}
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
const currentEditor = editorRef.current;
if (
!currentEditor ||
currentEditor !== targetEditor ||
currentEditor.isDestroyed
) {
return;
}
scrollEditorSelectionIntoView(currentEditor);
});
};
const extensions = useMemo(
() => [
...createAgentRichTextInputExtensions(
{
enableSuggestions: enableFileMentionSuggestions,
onSuggestionChange: (state) =>
onFileMentionSuggestionChangeRef.current?.(state),
onSuggestionKeyDown: (event) =>
onFileMentionSuggestionKeyDownRef.current?.(event) ?? false,
removeActionAriaLabel: removeMentionLabelRef.current,
shouldSuppressSuggestion: mentionSuggestionSuppression.isSuppressed
},
{ skills: availableSkillsRef.current },
{ capabilities: availableCapabilitiesRef.current }
),
createAgentRichTextCaretAnchorExtension(),
createAgentRichTextPlaceholderExtension(() => placeholderRef.current)
],
[enableFileMentionSuggestions]
);
onChangeRef.current = onChange;
onContentLayoutInvalidatedRef.current = onContentLayoutInvalidated;
onFocusRef.current = onFocus;
onUserContentChangeRef.current = onUserContentChange;
onSubmitRef.current = onSubmit;
onSubmitGuidanceRef.current = onSubmitGuidance;
onKeyDownForPaletteRef.current = onKeyDownForPalette;
onHistoryNavigationRef.current = onHistoryNavigation;
onFileMentionSuggestionChangeRef.current = onFileMentionSuggestionChange;
onFileMentionSuggestionKeyDownRef.current = onFileMentionSuggestionKeyDown;
onLinkClickRef.current = onLinkClick;
onPromptImagesUnsupportedRef.current = onPromptImagesUnsupported;
onPasteImagesRef.current = onPasteImages;
onPasteLargeTextRef.current = onPasteLargeText;
onPasteFilesRef.current = onPasteFiles;
onDropFilesRef.current = onDropFiles;
onResolvePastedPathRef.current = onResolvePastedPath;
promptImagesSupportedRef.current = promptImagesSupported;
placeholderRef.current = placeholder;
removeMentionLabelRef.current = removeMentionLabel;
availableSkillsRef.current = availableSkills;
availableCapabilitiesRef.current = availableCapabilities;
const editor = useEditor({
immediatelyRender: false,
editable: !disabled,
extensions,
content: plainTextToAgentRichTextDoc(value, {
capabilities: availableCapabilities,
skills: availableSkills
}),
editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder,
"aria-disabled": disabled ? "true" : "false",
"aria-multiline": "true",
class: cn(
className,
"overflow-y-auto whitespace-pre-wrap break-words [&_p]:m-0"
)
},
clipboardTextSerializer: (slice) =>
agentRichTextContentToPromptText(slice.content.toJSON()),
handleDOMEvents: {
click: (_view, event) => {
if (!onLinkClickRef.current || !(event.target instanceof Element)) {
return false;
}
const mention = event.target.closest(
'[data-agent-file-mention="true"]'
);
if (!(mention instanceof HTMLElement)) {
return false;
}
const href =
mention instanceof HTMLAnchorElement
? mention.getAttribute("href") || ""
: mention.getAttribute("data-agent-mention-href") || "";
if (!href) {
return false;
}
event.preventDefault();
event.stopPropagation();
onLinkClickRef.current(href);
return true;
},
contextmenu: (_view, event) => {
const currentEditor = editorRef.current;
if (!currentEditor || currentEditor.isDestroyed) {
return false;
}
event.preventDefault();
event.stopPropagation();
const stateSelection = currentEditor.state.selection;
const domSelection = stateSelection.empty
? readEditorDomSelectionRange(currentEditor)
: null;
const from = domSelection?.from ?? stateSelection.from;
const to = domSelection?.to ?? stateSelection.to;
setContextMenu({
canEdit: !disabled,
hasSelection: from < to,
selectionFrom: from,
selectionTo: to,
x: event.clientX,
y: event.clientY
});
return true;
},
copy: (_view, event) => {
const currentEditor = editorRef.current;
if (
!currentEditor ||
currentEditor.isDestroyed ||
!event.clipboardData
) {
return false;
}
const clipboard = serializeAgentRichTextSelection(currentEditor);
if (!clipboard) {
return false;
}
event.clipboardData.setData("text/plain", clipboard.text);
event.clipboardData.setData("text/html", clipboard.html);
event.preventDefault();
return true;
},
cut: (_view, event) => {
const currentEditor = editorRef.current;
if (
disabled ||
!currentEditor ||
currentEditor.isDestroyed ||
!event.clipboardData
) {
return false;
}
const selection = readPromptSelection(currentEditor);
const clipboard = serializeAgentRichTextSelection(currentEditor);
if (!clipboard) {
return false;
}
event.clipboardData.setData("text/plain", clipboard.text);
event.clipboardData.setData("text/html", clipboard.html);
event.preventDefault();
currentEditor.commands.deleteRange({
from: selection.from,
to: selection.to
});
return true;
},
paste: (_view, event) => {
const { externalFiles, imageFiles, imagesHandledAsFiles } =
routeAgentRichTextExternalFiles(event.clipboardData, {
externalFilesSupported: Boolean(onPasteFilesRef.current),
promptImagesSupported: promptImagesSupportedRef.current
});
if (imageFiles.length > 0 || externalFiles.length > 0) {
event.preventDefault();
if (externalFiles.length > 0) {
onPasteFilesRef.current?.(externalFiles);
}
if (imageFiles.length > 0 && !imagesHandledAsFiles) {
if (!promptImagesSupportedRef.current) {
onPromptImagesUnsupportedRef.current?.();
} else {
void readAgentRichTextPromptImages(imageFiles).then(
(images) => {
if (images.length > 0) {
onPasteImagesRef.current?.(images);
}
}
);
}
}
return true;
}
const html = event.clipboardData?.getData("text/html") ?? "";
const text = event.clipboardData?.getData("text/plain") ?? "";
const handled = routeAgentRichTextTextPaste({
availableCapabilities: availableCapabilitiesRef.current,
availableSkills: availableSkillsRef.current,
editorRef,
html,
mentionSuggestionSuppression,
onPasteLargeText: onPasteLargeTextRef.current,
resolvePastedPath: onResolvePastedPathRef.current,
text
});
if (handled) {
event.preventDefault();
}
return handled;
},
keydown: (_view, event) => {
if (isAgentRichTextImeComposing(event)) {
return false;
}
if (disabled) {
return false;
}
if (onKeyDownForPaletteRef.current?.(event)) {
return true;
}
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey) &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
if (!submitOnEnter) {
return true;
}
onSubmitGuidanceRef.current?.();
return true;
}
if (
event.key !== "Enter" ||
event.shiftKey ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return false;
}
if (!submitOnEnter) {
return false;
}
event.preventDefault();
onSubmitRef.current();
return true;
},
dragover: (_view, event) => {
const dataTransfer = event.dataTransfer;
if (!dataTransfer || disabled) {
return false;
}
const systemFileDragInfo =
systemFileDragInfoFromDataTransfer(dataTransfer);
const canDropRegularSystemFiles =
systemFileDragInfo.hasRegularFiles &&
Boolean(onDropFilesRef.current);
const canDropImagesAsFiles =
systemFileDragInfo.hasImageFiles &&
!promptImagesSupportedRef.current &&
Boolean(onDropFilesRef.current);
if (systemFileDragInfo.hasImageFiles || canDropRegularSystemFiles) {
event.preventDefault();
dataTransfer.dropEffect =
canDropRegularSystemFiles ||
canDropImagesAsFiles ||
(systemFileDragInfo.hasImageFiles &&
promptImagesSupportedRef.current)
? "copy"
: "none";
return true;
}
if (!hasWorkspaceFileDropData(dataTransfer)) {
return false;
}
const entries = readWorkspaceFileDropEntries(dataTransfer);
if (entries.length === 0) {
return false;
}
event.preventDefault();
dataTransfer.dropEffect = "copy";
return true;
},
drop: (_view, event) => {
const dataTransfer = event.dataTransfer;
if (!dataTransfer || disabled) {
return false;
}
const {
externalFiles: filesForExternalPreparation,
imageFiles,
imagesHandledAsFiles
} = routeAgentRichTextExternalFiles(dataTransfer, {
externalFilesSupported: Boolean(onDropFilesRef.current),
promptImagesSupported: promptImagesSupportedRef.current
});
if (imageFiles.length > 0 || filesForExternalPreparation.length > 0) {
event.preventDefault();
const currentEditor = editorRef.current;
if (
filesForExternalPreparation.length > 0 &&
onDropFilesRef.current &&
currentEditor &&
!currentEditor.isDestroyed
) {
const coordinatePosition = currentEditor.view.posAtCoords({
left: event.clientX,
top: event.clientY
})?.pos;
const selectionPosition = currentEditor.state.selection.from;
const insertPosition =
coordinatePosition ??
(Number.isInteger(selectionPosition)
? selectionPosition
: null) ??
currentEditor.state.doc.content.size;
currentEditor
.chain()
.focus()
.setTextSelection(insertPosition)
.run();
onDropFilesRef.current(filesForExternalPreparation);
}
if (imageFiles.length === 0 || imagesHandledAsFiles) {
return true;
}
if (!promptImagesSupportedRef.current) {
onPromptImagesUnsupportedRef.current?.();
return true;
}
void readAgentRichTextPromptImages(imageFiles).then((images) => {
if (images.length > 0) {
onPasteImagesRef.current?.(images);
}
});
return true;
}
if (!hasWorkspaceFileDropData(dataTransfer)) {
return false;
}
event.preventDefault();
const currentEditor = editorRef.current;
if (!currentEditor || currentEditor.isDestroyed) {
return true;
}
const entries = readWorkspaceFileDropEntries(dataTransfer);
if (entries.length === 0) {
return true;
}
const coordinatePosition = currentEditor.view.posAtCoords({
left: event.clientX,
top: event.clientY
})?.pos;
const fallbackSelectionPosition = currentEditor.state.selection.from;
const insertPosition =
coordinatePosition ??
(Number.isInteger(fallbackSelectionPosition)
? fallbackSelectionPosition
: null) ??
currentEditor.state.doc.content.size;
currentEditor
.chain()
.focus()
.insertContentAt(
insertPosition,
buildWorkspaceFileMentionDropContent(entries, {
prefixCaretAnchor: isPromptVisualLineStart(
currentEditor,
insertPosition
)
})
)
.run();
return true;
}
}
},
onUpdate: ({ editor: nextEditor, transaction }) => {
editorRef.current = nextEditor;
scheduleSelectionScroll(nextEditor);
onContentLayoutInvalidatedRef.current?.();
const nextPrompt = editorToPromptText(nextEditor);
if (nextPrompt === lastEmittedPromptRef.current) {
return;
}
lastEmittedPromptRef.current = nextPrompt;
recordAgentRichTextLocalEdit(
controlledValueTrackerRef.current,
nextPrompt
);
if (isAgentRichTextUserContentInsertion(transaction)) {
onUserContentChangeRef.current?.(nextPrompt);
}
onChangeRef.current(nextPrompt);
},
onFocus: ({ editor: nextEditor }) => {
const pendingMethod = pendingFocusMethodRef.current;
const focusMethod =
pendingMethod ??
(nextEditor.view.dom.matches(":focus-visible")
? "keyboard"
: "programmatic");
pendingFocusMethodRef.current = null;
onFocusRef.current?.(focusMethod);
},
onBlur: () => {
pendingFocusMethodRef.current = null;
},
onCreate: ({ editor: nextEditor }) => {
editorRef.current = nextEditor;
nextEditor.commands.setTextSelection(nextEditor.state.doc.content.size);
}
});
const handleKeyDownCapture = (
event: ReactKeyboardEvent<HTMLDivElement>
): void => {
mentionSuggestionSuppression.releaseForContentEditingKey(event);
handleAgentRichTextKeyDownCapture(event, {
disabled,
editorRef,
onKeyDownForPaletteRef,
onHistoryNavigationRef,
onSubmitGuidanceRef,
onSubmitRef,
submitOnEnter
});
};
useEffect(
() => () => {
if (
scrollFrameRef.current !== null &&
typeof window.cancelAnimationFrame === "function"
) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
},
[]
);
useEffect(() => {
if (!contextMenu) {
return;
}
const close = () => closeContextMenu();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeContextMenu();
}
};
window.addEventListener("pointerdown", close);
window.addEventListener("resize", close);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", closeOnEscape);
};
}, [closeContextMenu, contextMenu]);
useAgentRichTextEditorHandle({
availableCapabilitiesRef,
availableSkillsRef,
editorRef,
onBeforeProgrammaticFocus: () => {
pendingFocusMethodRef.current = "programmatic";
},
ref
});
useEffect(() => {
if (!editor || editor.isDestroyed) {
return;
}
if (
!shouldApplyAgentRichTextControlledValue({
lastEmittedValue: lastEmittedPromptRef.current,
scopeKey: contentScopeKey,
tracker: controlledValueTrackerRef.current,
value
})
) {
return;
}
const nextDoc = plainTextToAgentRichTextDoc(value, {
capabilities: availableCapabilities,
skills: availableSkills
});
const scopeChanged = appliedContentScopeKeyRef.current !== contentScopeKey;
const previousSelection = editor.state.selection;
mentionSuggestionSuppression.setRestoredValue(value);
if (JSON.stringify(editor.getJSON()) === JSON.stringify(nextDoc)) {
lastEmittedPromptRef.current = value;
appliedContentScopeKeyRef.current = contentScopeKey;
return;
}
editor.commands.setContent(nextDoc, { emitUpdate: false });
const documentEnd = editor.state.doc.content.size;
editor.commands.setTextSelection(
resolveAgentRichTextControlledSelection(
scopeChanged,
previousSelection,
documentEnd
)
);
lastEmittedPromptRef.current = value;
appliedContentScopeKeyRef.current = contentScopeKey;
onContentLayoutInvalidatedRef.current?.();
}, [availableCapabilities, availableSkills, contentScopeKey, editor, value]);
return (
<AgentRichTextEditorSurface
className={className}
contextMenu={contextMenu}
copySelection={copySelection}
cutSelection={cutSelection}
disabled={disabled}
editor={editor}
handleKeyDownCapture={handleKeyDownCapture}
handlePointerDownCapture={() =>
markAgentRichTextPointerFocus(pendingFocusMethodRef)
}
pasteClipboardText={pasteClipboardText}
placeholder={placeholder}
testId={testId}
t={t}
/>
);
});