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

536 lines
14 KiB
TypeScript

import { Extension, type Editor, type JSONContent } from "@tiptap/core";
import {
DOMParser as ProseMirrorDOMParser,
DOMSerializer,
type Node as ProseMirrorNode
} from "@tiptap/pm/model";
import {
Plugin,
PluginKey,
TextSelection,
type EditorState,
type Transaction
} from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { AgentFileMentionKind } from "./agentFileMentionExtension";
import { AGENT_RICH_TEXT_CARET_ANCHOR } from "./agentRichTextCaretAnchor";
import { agentRichTextContentToPromptText } from "./agentRichTextDocument";
export const AGENT_RICH_TEXT_LARGE_PASTE_MIN_CHARS = 5_000;
export function isAgentRichTextLargeTextPaste(text: string): boolean {
return text.trim().length >= AGENT_RICH_TEXT_LARGE_PASTE_MIN_CHARS;
}
export type AgentRichTextTextPasteKind =
| "empty"
| "large-text"
| "plain-text"
| "structured-mention";
export interface AgentRichTextClipboardPayload {
html: string;
text: string;
}
export function serializeAgentRichTextSelection(
editor: Editor
): AgentRichTextClipboardPayload | null {
const selection = readPromptSelection(editor);
if (!selection.text) {
return null;
}
const slice = editor.state.doc.slice(selection.from, selection.to);
const wrapper = document.createElement("div");
wrapper.appendChild(
DOMSerializer.fromSchema(editor.schema).serializeFragment(slice.content)
);
return { html: wrapper.innerHTML, text: selection.text };
}
export function parseAgentRichTextClipboardHtml(
editor: Editor,
html: string
): JSONContent[] | null {
if (!html.includes("data-agent-file-mention")) {
return null;
}
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
const slice = ProseMirrorDOMParser.fromSchema(editor.schema).parseSlice(
wrapper,
{ preserveWhitespace: true }
);
return slice.content.toJSON();
}
export function insertAgentRichTextClipboardHtml(
editor: Editor,
html: string
): boolean {
if (!editor.isFocused) {
editor.commands.setTextSelection(editor.state.doc.content.size);
}
const content = parseAgentRichTextClipboardHtml(editor, html);
if (!content) {
return false;
}
editor.commands.insertContent(content);
return true;
}
export function classifyAgentRichTextTextPaste(
text: string,
html: string,
largeTextHandlingAvailable: boolean
): AgentRichTextTextPasteKind {
if (!text) {
return "empty";
}
if (largeTextHandlingAvailable && isAgentRichTextLargeTextPaste(text)) {
return "large-text";
}
return html.includes("data-agent-file-mention")
? "structured-mention"
: "plain-text";
}
/**
* Hosts may resolve a single absolute path paste into a file/directory mention.
* Keep the sync gate strict so quoted paths, spaces, and multi-path pastes stay
* plain text until a host explicitly resolves them.
*/
export function isAgentRichTextAbsolutePathPasteCandidate(
text: string
): boolean {
const candidate = text.trim();
if (!candidate || candidate[0] !== "/") {
return false;
}
// Reject multi-line and whitespace/quotes inside the path.
if (/[\s"'`]/.test(candidate)) {
return false;
}
return true;
}
export function buildWorkspaceFileMentionDropContent(
entries: ReadonlyArray<{
path: string;
name: string;
kind: AgentFileMentionKind;
}>,
options: { prefixCaretAnchor?: boolean } = {}
): JSONContent[] {
return entries.flatMap((entry, index) => [
...(index === 0 && options.prefixCaretAnchor
? ([
{ type: "text", text: AGENT_RICH_TEXT_CARET_ANCHOR }
] as JSONContent[])
: []),
{
type: "agentFileMention",
attrs: {
kind: "file",
href: entry.path,
path: entry.path,
name: entry.name,
entryKind: entry.kind
}
},
{ type: "text", text: " " }
]);
}
export function isPromptVisualLineStart(
editor: Editor,
position: number
): boolean {
if (position <= 1) {
return true;
}
return (
editor.state.doc.textBetween(
Math.max(1, position - 1),
position,
"\n",
"\n"
) === "\n"
);
}
export function isMentionTriggerBoundaryBeforeSelection(
editor: Editor
): boolean {
const position = editor.state.selection.from;
if (position <= 1) {
return true;
}
const previous = editor.state.doc.textBetween(
Math.max(1, position - 1),
position,
"\n",
"\n"
);
return previous === "" || /\s/.test(previous);
}
export function findCaretAnchorBeforeAtomicRun(
doc: ProseMirrorNode,
position: number
): number | null {
let anchorPosition: number | null = null;
doc.descendants((node, nodePosition) => {
if (nodePosition >= position) {
return false;
}
if (node.isText) {
const text = node.text ?? "";
for (let offset = 0; offset < text.length; offset += 1) {
const characterPosition = nodePosition + offset;
if (characterPosition >= position) {
return false;
}
if (text[offset] === AGENT_RICH_TEXT_CARET_ANCHOR) {
anchorPosition = characterPosition;
continue;
}
if (anchorPosition !== null) {
anchorPosition = null;
}
}
return true;
}
if (node.type.name === "agentFileMention") {
return false;
}
if (node.isInline && anchorPosition !== null) {
anchorPosition = null;
}
return true;
});
return anchorPosition;
}
export function moveSelectionOverCaretAnchor(
state: EditorState,
dispatch: (transaction: Transaction) => void,
key: string
): boolean {
const { doc, selection } = state;
if (!selection.empty) {
return false;
}
const position = selection.from;
if (key === "ArrowLeft") {
const anchorPosition = findCaretAnchorBeforeAtomicRun(doc, position);
if (anchorPosition === null) {
return false;
}
dispatch(state.tr.setSelection(TextSelection.create(doc, anchorPosition)));
return true;
}
if (
key === "ArrowRight" &&
position < doc.content.size &&
doc.textBetween(position, position + 1) === AGENT_RICH_TEXT_CARET_ANCHOR
) {
const afterAnchor = position + 1;
const mentionAfterAnchor = doc.resolve(afterAnchor).nodeAfter;
dispatch(
state.tr.setSelection(
TextSelection.create(
doc,
afterAnchor +
(mentionAfterAnchor?.type.name === "agentFileMention"
? mentionAfterAnchor.nodeSize
: 0)
)
)
);
return true;
}
return false;
}
export function deleteLineStartMentionWithCaretAnchor(
state: EditorState,
dispatch: (transaction: Transaction) => void,
key: string
): boolean {
const { doc, selection } = state;
if (!selection.empty) {
return false;
}
if (key === "Backspace") {
const mention = selection.$from.nodeBefore;
if (mention?.type.name !== "agentFileMention") {
return false;
}
const mentionFrom = selection.from - mention.nodeSize;
const anchorFrom = mentionFrom - AGENT_RICH_TEXT_CARET_ANCHOR.length;
if (
anchorFrom < 1 ||
doc.textBetween(anchorFrom, mentionFrom) !== AGENT_RICH_TEXT_CARET_ANCHOR
) {
return false;
}
const nextNode = selection.$from.nodeAfter;
dispatch(
state.tr.delete(
nextNode?.type.name === "agentFileMention" ? mentionFrom : anchorFrom,
selection.from
)
);
return true;
}
if (key !== "Delete") {
return false;
}
const anchorFrom = selection.from;
const mentionFrom = anchorFrom + AGENT_RICH_TEXT_CARET_ANCHOR.length;
if (
doc.textBetween(anchorFrom, mentionFrom) !== AGENT_RICH_TEXT_CARET_ANCHOR
) {
return false;
}
const mention = doc.resolve(mentionFrom).nodeAfter;
if (mention?.type.name !== "agentFileMention") {
return false;
}
const mentionTo = mentionFrom + mention.nodeSize;
const nextNode = doc.resolve(mentionTo).nodeAfter;
dispatch(
state.tr.delete(
nextNode?.type.name === "agentFileMention" ? mentionFrom : anchorFrom,
mentionTo
)
);
return true;
}
export function createAgentRichTextPlaceholderExtension(
getPlaceholder: () => string
): Extension {
return Extension.create({
name: "agentRichTextPlaceholder",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("agentRichTextPlaceholder"),
props: {
decorations(state) {
const firstNode = state.doc.firstChild;
const isEmptyPrompt =
state.doc.childCount === 1 &&
firstNode?.type.name === "paragraph" &&
firstNode.content.size === 0;
if (!firstNode || !isEmptyPrompt) {
return DecorationSet.empty;
}
return DecorationSet.create(state.doc, [
Decoration.node(0, firstNode.nodeSize, {
class: "agent-rich-text-placeholder-node",
"data-agent-rich-text-placeholder": getPlaceholder()
})
]);
}
}
})
];
}
});
}
export function createAgentRichTextCaretAnchorExtension(): Extension {
return Extension.create({
name: "agentRichTextCaretAnchor",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("agentRichTextCaretAnchor"),
props: {
handleKeyDown(view, event) {
if (
(event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "Backspace" &&
event.key !== "Delete") ||
event.shiftKey ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return false;
}
if (
deleteLineStartMentionWithCaretAnchor(
view.state,
(transaction) => view.dispatch(transaction),
event.key
)
) {
event.preventDefault();
return true;
}
if (
moveSelectionOverCaretAnchor(
view.state,
(transaction) => view.dispatch(transaction),
event.key
)
) {
event.preventDefault();
return true;
}
return false;
}
}
})
];
}
});
}
export function scrollEditorSelectionIntoView(editor: Editor): void {
const scrollContainer = editor.view.dom;
if (!(scrollContainer instanceof HTMLElement)) {
return;
}
const maxScrollTop =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
if (maxScrollTop <= 0) {
return;
}
const promptEnd = editor.state.doc.content.size;
if (editor.state.selection.to >= Math.max(0, promptEnd - 1)) {
scrollContainer.scrollTop = maxScrollTop;
return;
}
const selectionRect = editor.view.coordsAtPos(editor.state.selection.head);
const containerRect = scrollContainer.getBoundingClientRect();
const overflowBottom = selectionRect.bottom - containerRect.bottom;
if (overflowBottom > 0) {
scrollContainer.scrollTop = Math.min(
maxScrollTop,
scrollContainer.scrollTop + overflowBottom
);
return;
}
const overflowTop = containerRect.top - selectionRect.top;
if (overflowTop > 0) {
scrollContainer.scrollTop = Math.max(
0,
scrollContainer.scrollTop - overflowTop
);
}
}
export function readSelectedPlainText(editor: Editor): string {
return readPromptSelection(editor).text;
}
export function readPromptSelection(editor: Editor): {
from: number;
text: string;
to: number;
} {
const selection = editor.state.selection;
const domSelection = selection.empty
? readEditorDomSelectionRange(editor)
: null;
const from = domSelection?.from ?? selection.from;
const to = domSelection?.to ?? selection.to;
return {
from,
text: readPromptTextRange(editor, from, to),
to
};
}
export function readPromptTextRange(
editor: Editor,
from: number,
to: number
): string {
if (from === to) {
return "";
}
return agentRichTextContentToPromptText(
editor.state.doc.slice(from, to).content.toJSON()
);
}
export function readEditorDomSelectionRange(
editor: Editor
): { from: number; to: number } | null {
const dom = editor.view.dom;
const selection = dom.ownerDocument.getSelection();
if (
!selection ||
selection.rangeCount === 0 ||
!selection.anchorNode ||
!selection.focusNode ||
selection.isCollapsed ||
!dom.contains(selection.anchorNode) ||
!dom.contains(selection.focusNode)
) {
return null;
}
try {
const anchor = editor.view.posAtDOM(
selection.anchorNode,
selection.anchorOffset
);
const focus = editor.view.posAtDOM(
selection.focusNode,
selection.focusOffset
);
if (anchor === focus) {
return null;
}
return {
from: Math.min(anchor, focus),
to: Math.max(anchor, focus)
};
} catch {
return null;
}
}
export async function writePlainTextToClipboard(
text: string
): Promise<boolean> {
if (!text || typeof navigator.clipboard?.writeText !== "function") {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export async function readPlainTextFromClipboard(): Promise<string | null> {
if (typeof navigator.clipboard?.readText !== "function") {
return null;
}
try {
return await navigator.clipboard.readText();
} catch {
return null;
}
}