源码
packages/ui/src/components/Reference.tsx
import * as ContextMenu from "@radix-ui/react-context-menu";
import { parseMentions } from "@zpi-dev/coding-agent/input";
import { Copy, ExternalLink } from "lucide-react";
import { type CSSProperties, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { selectionIntersects } from "../conversation-selections.ts";
import { fallbackFileIconSource, fileIconSource } from "../file-icons.ts";
import type { FileLocation } from "../link-target.ts";
import type { FileAction, FileActionHandler } from "../types.ts";
import { userErrorMessage } from "../user-error.ts";
import { Notice } from "./Notice.tsx";
export interface DisplayReference {
kind: "file" | "skill" | "command" | "session" | "subagent";
label: string;
path: string;
start: number;
end: number;
markdown: string;
}
// Commands keep their canonical slash text. Presentation never changes the Pi input protocol.
export function displayReferences(
text: string,
commandNames: readonly string[] = [],
subagentNames: readonly string[] = [],
): DisplayReference[] {
const references: DisplayReference[] = parseMentions(text);
const names = new Set(commandNames.map((name) => name.toLowerCase()));
for (const match of text.matchAll(
/(^|\s)\/([a-z0-9_-][a-z0-9_:-]*(?:\/[a-z0-9_-][a-z0-9_:-]*)*)(?=\s|$)/gi,
)) {
const name = match[2].toLowerCase().replaceAll(":", "/");
if (name === "side" || name === "btw") continue;
if (match[2] !== "init" && match[2] !== "compact" && !names.has(name)) continue;
const start = match.index + match[1].length;
if (references.some((reference) => start >= reference.start && start < reference.end)) continue;
const markdown = `/${match[2]}`;
references.push({
kind: "command",
label: name === "init" ? "Init" : name === "compact" ? "Compact" : name,
path: "",
start,
end: start + markdown.length,
markdown,
});
}
const agents = new Map(subagentNames.map((name) => [name.toLowerCase(), name]));
for (const match of text.matchAll(/(^|\s)@([a-z0-9._-]+)(?=\s|$)/gi)) {
const label = agents.get(match[2].toLowerCase());
if (!label) continue;
const start = match.index + match[1].length;
if (references.some((reference) => start >= reference.start && start < reference.end)) continue;
const markdown = `@${match[2]}`;
references.push({ kind: "subagent", label, path: "", start, end: start + markdown.length, markdown });
}
return references.sort((a, b) => a.start - b.start);
}
export function messageReferences(
text: string,
paths: readonly string[] = [],
commandNames: readonly string[] = [],
agentNames: readonly string[] = [],
) {
const inline = displayReferences(text, commandNames, agentNames);
const trailing = paths
.filter((path) => !inline.some((reference) => reference.path === path))
.map((path) => ({ path, label: path.split("/").at(-1) ?? path }));
return { inline, trailing };
}
export function referenceStyle(kind: DisplayReference["kind"], path: string): CSSProperties {
const source =
kind === "file"
? fileIconSource(path)
: kind === "subagent"
? `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2m16 0h2m-7-1v2m-6-2v2"/></svg>')}`
: kind === "command" && !["init", "compact"].includes(path.toLowerCase())
? `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 15 6-6"/></svg>')}`
: new URL(
`./mention-icons/${kind === "skill" || kind === "session" ? kind : path.toLowerCase()}.svg`,
document.baseURI,
).href;
return {
[kind === "file" ? "--mention-image" : "--mention-mask"]: `url(${JSON.stringify(source)})`,
...(kind === "file" ? {} : { "--mention-icon-color": "currentColor" }),
} as CSSProperties;
}
export function FileIcon({ path, size = 16 }: { path: string; size?: number }) {
const source = fileIconSource(path);
return (
<img
key={source}
className="file-type-icon"
src={source}
width={size}
height={size}
alt=""
aria-hidden="true"
onError={(event) => {
const fallback = fallbackFileIconSource(event.currentTarget.src);
if (fallback) event.currentTarget.src = fallback;
}}
/>
);
}
export function Reference({
kind,
path,
label,
onOpen,
onAction,
location,
className = "",
}: {
kind: DisplayReference["kind"];
path: string;
label: string;
onOpen?: (path: string, location?: FileLocation) => void;
onAction?: FileActionHandler;
location?: FileLocation;
className?: string;
}) {
const anchor = useRef<HTMLButtonElement>(null);
const [error, setError] = useState("");
const perform = (action: FileAction) => {
setError("");
void onAction?.(path, action, location).catch((error) => setError(userErrorMessage(error)));
};
const style = referenceStyle(kind, kind === "command" ? label : path);
const reference =
kind === "file" && onOpen ? (
<button
ref={anchor}
type="button"
className={`inline-mention file ${className}`}
data-conversation-inline-link="true"
style={style}
title={path}
onClick={(event) => {
if (selectionIntersects(event.currentTarget)) return;
if (onAction && (event.metaKey || event.ctrlKey)) perform("open");
else onOpen(path, location);
}}
>
<span className="reference-label">{label}</span>
</button>
) : (
<span className={`inline-mention ${kind} ${className}`} style={style} title={path || undefined}>
{label}
</span>
);
if (kind !== "file" || !onOpen || !onAction) return reference;
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger asChild>{reference}</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content className="message-link-menu message-file-menu">
<ContextMenu.Item onSelect={() => onOpen(path, location)}>打开</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => perform("reveal")}>
<img
src={new URL("./file-actions/finder.png", document.baseURI).href}
width={16}
height={16}
alt=""
/>
<span>Finder</span>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => perform("open")}>
<ExternalLink size={16} aria-hidden="true" />
<span>使用默认程序打开</span>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => perform("copy-absolute")}>
<Copy size={16} aria-hidden="true" />
<span>复制绝对路径</span>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => perform("copy-relative")}>
<Copy size={16} aria-hidden="true" />
<span>复制相对路径</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
{error &&
createPortal(
<Notice scope="window" message={error} onDismiss={() => setError("")} />,
anchor.current?.closest("dialog, .shell") ?? document.body,
)}
</>
);
}