Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { hasFileMentions, parseFileMentions } from "./parseFileMentions";

function renderParts(content: string) {
return render(<Theme>{parseFileMentions(content)}</Theme>);
}

describe("parseFileMentions", () => {
it("highlights a leading slash command", () => {
renderParts("/deploy the app");
expect(screen.getByText("/deploy")).toBeInTheDocument();
});

it("highlights every slash command, not just the first", () => {
// A submitted prompt using several skills arrives with its <skill /> tags
// already flattened to plain /name text.
renderParts("/first do a thing then /second and finally /third");
expect(screen.getByText("/first")).toBeInTheDocument();
expect(screen.getByText("/second")).toBeInTheDocument();
expect(screen.getByText("/third")).toBeInTheDocument();
});

it("highlights a slash command that is not at the start of the string", () => {
renderParts("please run /cleanup now");
expect(screen.getByText("/cleanup")).toBeInTheDocument();
});

it("does not treat a mid-word slash as a command", () => {
renderParts("check the and/or logic");
expect(screen.queryByText("/or")).not.toBeInTheDocument();
});

it("renders slash commands alongside file mentions", () => {
renderParts('/review <file path="src/app/main.ts" /> then /ship');
expect(screen.getByText("/review")).toBeInTheDocument();
expect(screen.getByText("app/main.ts")).toBeInTheDocument();
expect(screen.getByText("/ship")).toBeInTheDocument();
});

it("detects slash commands anywhere for hasFileMentions", () => {
expect(hasFileMentions("run /skill please")).toBe(true);
expect(hasFileMentions("no commands here")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ const MENTION_TAG_REGEX =
/<file\s+path="([^"]+)"\s*\/>|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|<error_context\s+label="([^"]*)">[\s\S]*?<\/error_context>|<folder\s+path="([^"]+)"\s*\/>/g;
const MENTION_TAG_TEST =
/<(?:file\s+path|folder\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label)="[^"]+"/;
const SLASH_COMMAND_START = /^\/([a-zA-Z][\w-]*)(?=\s|$)/;
// Matches every slash command in the string, at the start or after whitespace —
// not just the leading one. Submitted prompts have their <skill /> tags flattened
// to plain /name text, so a prompt using several skills arrives here as multiple
// /name tokens that each need a chip.
const SLASH_COMMAND_REGEX = /(^|\s)\/([a-zA-Z][\w-]*)(?=\s|$)/g;
const SLASH_COMMAND_TEST = /(?:^|\s)\/[a-zA-Z][\w-]*(?=\s|$)/;

const inlineComponents: Components = {
...baseComponents,
Expand All @@ -42,7 +47,7 @@ export const InlineMarkdown = memo(function InlineMarkdown({
});

export function hasMentionTags(content: string): boolean {
return MENTION_TAG_TEST.test(content) || SLASH_COMMAND_START.test(content);
return MENTION_TAG_TEST.test(content) || SLASH_COMMAND_TEST.test(content);
}

export const hasFileMentions = hasMentionTags;
Expand Down Expand Up @@ -88,43 +93,49 @@ export function MentionChip({
);
}

export function parseMentionTags(content: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
interface MentionMatch {
start: number;
end: number;
node: ReactNode;
}

const slashMatch = content.match(SLASH_COMMAND_START);
if (slashMatch) {
parts.push(
<MentionChip key="slash-cmd" icon={null} label={`/${slashMatch[1]}`} />,
);
lastIndex = slashMatch[0].length;
function collectMentionMatches(content: string): MentionMatch[] {
const matches: MentionMatch[] = [];

for (const match of content.matchAll(SLASH_COMMAND_REGEX)) {
// Group 1 is the leading start-of-string/whitespace boundary; keep it as
// surrounding text so only the /command itself becomes a chip.
const leading = match[1] ?? "";
const start = (match.index ?? 0) + leading.length;
matches.push({
start,
end: start + match[0].length - leading.length,
node: (
<MentionChip
key={`slash-${start}`}
icon={null}
label={`/${match[2]}`}
/>
),
});
}

for (const match of content.matchAll(MENTION_TAG_REGEX)) {
const matchIndex = match.index ?? 0;
if (matchIndex < lastIndex) continue;

if (matchIndex > lastIndex) {
parts.push(
<InlineMarkdown
key={`text-${lastIndex}`}
content={content.slice(lastIndex, matchIndex)}
/>,
);
}
let node: ReactNode = null;

if (match[1]) {
const filePath = unescapeXmlAttr(match[1]);
const segments = filePath.split("/").filter(Boolean);
const fileName = segments.pop() ?? filePath;
const parentDir = segments.pop();
const label = parentDir ? `${parentDir}/${fileName}` : fileName;
parts.push(
node = (
<MentionChip
key={`file-${matchIndex}`}
icon={<File size={12} />}
label={label}
/>,
/>
);
} else if (match[2]) {
const kind = match[2] === "github_pr" ? "pr" : "issue";
Expand All @@ -134,37 +145,66 @@ export function parseMentionTags(content: string): ReactNode[] {
const label = issueTitle
? `#${issueNumber} - ${issueTitle}`
: `#${issueNumber}`;
parts.push(
node = (
<GithubRefChip
key={`${match[2]}-${matchIndex}`}
href={issueUrl}
kind={kind}
>
{label}
</GithubRefChip>,
</GithubRefChip>
);
} else if (match[6]) {
parts.push(
node = (
<MentionChip
key={`error-ctx-${matchIndex}`}
icon={<Warning size={12} />}
label={unescapeXmlAttr(match[6])}
/>,
/>
);
} else if (match[7]) {
const folderPath = unescapeXmlAttr(match[7]);
const segments = folderPath.split("/").filter(Boolean);
const folderName = segments.pop() ?? folderPath;
parts.push(
node = (
<MentionChip
key={`folder-${matchIndex}`}
icon={<Folder size={12} />}
label={folderName}
/>
);
}

if (node) {
matches.push({
start: matchIndex,
end: matchIndex + match[0].length,
node,
});
}
}

return matches.sort((a, b) => a.start - b.start);
}

export function parseMentionTags(content: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;

for (const { start, end, node } of collectMentionMatches(content)) {
if (start < lastIndex) continue;

if (start > lastIndex) {
parts.push(
<InlineMarkdown
key={`text-${lastIndex}`}
content={content.slice(lastIndex, start)}
/>,
);
}

lastIndex = matchIndex + match[0].length;
parts.push(node);
lastIndex = end;
}

if (lastIndex < content.length) {
Expand Down
Loading