diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393c6ac143..c2102d8924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -848,12 +848,18 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 remark-math: specifier: ^6.0.0 version: 6.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 remove-markdown: specifier: ^0.6.4 version: 0.6.4 @@ -881,6 +887,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.3.2) + unified: + specifier: ^11.0.5 + version: 11.0.5 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -6149,6 +6158,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -7152,6 +7164,9 @@ packages: rehype-react@6.2.1: resolution: {integrity: sha512-f9KIrjktvLvmbGc7si25HepocOg4z0MuNOtweigKzBcDjiGSTGhyz6VSgaV5K421Cq1O+z4/oxRJ5G9owo0KVg==} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -14412,6 +14427,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -15684,6 +15704,12 @@ snapshots: '@mapbox/hast-util-table-cell-style': 0.2.1 hast-to-hyperscript: 9.0.1 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/webview-ui/package.json b/webview-ui/package.json index 83777bcbf1..cebc26bc4d 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -68,8 +68,10 @@ "react-use": "^17.5.1", "react-virtuoso": "^4.7.13", "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", @@ -79,6 +81,7 @@ "tailwind-merge": "^3.0.0", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "use-sound": "^5.0.0", "vscode-material-icons": "^0.1.1", diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..098e251981 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -29,6 +29,8 @@ import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import MarkdownBlock from "../common/MarkdownBlock" + export interface TaskHeaderProps { task: ClineMessage tokensIn: number @@ -163,7 +165,9 @@ const TaskHeader = ({ e.target.closest('[role="button"]') || e.target.closest("[data-radix-popper-content-wrapper]") || e.target.closest("img") || - e.target.tagName === "IMG") + e.target.tagName === "IMG" || + e.target.closest("a") || + e.target.tagName === "A") ) { return } @@ -324,13 +328,13 @@ const TaskHeader = ({ className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
.
+ const bold = await screen.findByText("bold")
+ expect(bold.tagName).toBe("STRONG")
+ expect(container.querySelector("code")?.textContent).toBe("code")
+
+ // The raw markdown source must not be displayed verbatim in the expanded view.
+ expect(screen.queryByText("**bold** and `code`")).not.toBeInTheDocument()
+ })
+
+ it("uses the shared scrollable style for the expanded prompt box", () => {
+ const { container } = renderTaskHeader({
+ task: { type: "say", ts: Date.now(), text: "prompt", images: [] },
+ })
+
+ // Expand the header.
+ fireEvent.click(screen.getByText("prompt"))
+
+ // The prompt box must use the VS Code-style .scrollable scrollbar (hover-reveal),
+ // not a default always-visible Chromium scrollbar, so it matches the message list.
+ const scrollBox = container.querySelector(".scrollable")
+ expect(scrollBox).not.toBeNull()
+ expect(scrollBox?.className).toContain("max-h-80")
+ })
+
+ it("renders headings and lists in the expanded view", async () => {
+ const { container } = renderTaskHeader({
+ task: {
+ type: "say",
+ ts: Date.now(),
+ text: "# Heading\n- item one\n- item two",
+ images: [],
+ },
+ })
+
+ // Expand via the header container (the raw multi-line title is not a stable text target).
+ fireEvent.click(container.querySelector(".cursor-pointer")!)
+
+ const heading = await screen.findByRole("heading")
+ expect(heading.textContent).toBe("Heading")
+ expect(container.querySelector("ul li")).not.toBeNull()
+ })
+
+ it("does not collapse the panel when a rendered markdown link is clicked", async () => {
+ const { container } = renderTaskHeader({
+ task: {
+ type: "say",
+ ts: Date.now(),
+ text: "**bold** [example](https://example.com)",
+ images: [],
+ },
+ })
+
+ // Expand the header.
+ fireEvent.click(screen.getByText("**bold** [example](https://example.com)"))
+ const link = await screen.findByRole("link", { name: "example" })
+
+ // Clicking a rendered link must not toggle isTaskExpanded (the header click
+ // handler ignores anchor targets), so the expanded content stays visible.
+ fireEvent.click(link)
+ expect(container.querySelector("strong")).not.toBeNull()
+ })
+
+ it("keeps context mentions clickable in the expanded markdown view", async () => {
+ const { container } = renderTaskHeader({
+ task: {
+ type: "say",
+ ts: Date.now(),
+ text: "Inspect @/src/file.ts, @problems, and @terminal.",
+ images: [],
+ },
+ })
+
+ // Expand via the header container because the collapsed title contains split mention spans.
+ fireEvent.click(container.querySelector(".cursor-pointer")!)
+ await screen.findByText(/Inspect/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions).toHaveLength(3)
+ expect(mentions[0].textContent).toBe("@/src/file.ts")
+ expect(mentions[1].textContent).toBe("@problems")
+ expect(mentions[2].textContent).toBe("@terminal")
+
+ fireEvent.click(mentions[0])
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/file.ts" })
+
+ // The mention click must not bubble to the header toggle (the mention handler
+ // stops propagation), so the expanded markdown stays rendered after the
+ // mention is opened instead of the panel collapsing.
+ expect(screen.getByText(/Inspect/, { exact: false })).toBeInTheDocument()
+ expect(container.querySelectorAll("span.mention-context-highlight")).toHaveLength(3)
+ })
+
+ it("keeps single newlines as line breaks in a plain-text prompt", async () => {
+ const { container } = renderTaskHeader({
+ task: { type: "say", ts: Date.now(), text: "Fix the login bug\nIt crashes on startup", images: [] },
+ })
+
+ // Expand via the header container (the raw multi-line title is not a stable text target).
+ fireEvent.click(container.querySelector(".cursor-pointer")!)
+
+ // Inexact match: the soft break splits the paragraph into text
text, so no
+ // single element's full text equals the first line.
+ await screen.findByText(/Fix the login bug/, { exact: false })
+
+ // The previous expanded view rendered plain text with whitespace-pre-wrap, so a
+ // single newline was always a line break. The markdown pipeline collapses soft
+ // breaks to spaces per CommonMark unless remark-breaks is enabled, so the header
+ // must keep the newline structural (
) instead of reflowing the prompt into
+ // one paragraph.
+ const paragraph = container.querySelector(".scrollable p")
+ expect(paragraph).not.toBeNull()
+ expect(paragraph?.querySelector("br")).not.toBeNull()
+ expect(paragraph?.textContent).toBe("Fix the login bugIt crashes on startup")
+ })
+
+ it("still parses markdown headings and lists while keeping newlines inside them", async () => {
+ const { container } = renderTaskHeader({
+ task: {
+ type: "say",
+ ts: Date.now(),
+ text: "# Heading\n- item one\n continued line\n- item two",
+ images: [],
+ },
+ })
+
+ // Expand via the header container (the raw multi-line title is not a stable text target).
+ fireEvent.click(container.querySelector(".cursor-pointer")!)
+
+ const heading = await screen.findByRole("heading")
+ expect(heading.textContent).toBe("Heading")
+
+ // Markdown still parses (the # line is a heading, the - lines are list items)...
+ const items = container.querySelectorAll(".scrollable li")
+ expect(items).toHaveLength(2)
+
+ // ...and the soft break inside the first item renders as a line break.
+ expect(items[0]?.querySelector("br")).not.toBeNull()
+ expect(items[0]?.textContent).toBe("item onecontinued line")
+ expect(items[1]?.textContent).toBe("item two")
+ })
+
+ it("renders an empty prompt without crashing", () => {
+ const { container } = renderTaskHeader({
+ // `text` is optional on ClineMessage; omit it to exercise the empty-prompt path.
+ task: { type: "say", ts: Date.now(), images: [] },
+ })
+
+ // No title text to click, so expand via the header container itself.
+ fireEvent.click(container.querySelector(".cursor-pointer")!)
+
+ // The empty prompt renders nothing but must not crash; the rest of the
+ // expanded header (cost row) is still present.
+ expect(screen.getByText("$0.05")).toBeInTheDocument()
+ })
+ })
})
diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx
index 02f696553f..76c93422ba 100644
--- a/webview-ui/src/components/common/MarkdownBlock.tsx
+++ b/webview-ui/src/components/common/MarkdownBlock.tsx
@@ -3,8 +3,13 @@ import ReactMarkdown from "react-markdown"
import styled from "styled-components"
import { visit } from "unist-util-visit"
import rehypeKatex from "rehype-katex"
-import remarkMath from "remark-math"
+import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
+import remarkMath from "remark-math"
+import remarkParse from "remark-parse"
+import { unified } from "unified"
+
+import { mentionRegexGlobal } from "@roo/context-mentions"
import { vscode } from "@src/utils/vscode"
import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown"
@@ -12,6 +17,228 @@ import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown"
import CodeBlock from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"
+// Control character that wraps a mention index in the preprocessed markdown.
+// It cannot be typed into a prompt and carries no markdown meaning, so remark
+// always keeps a whole placeholder inside a single text node. Built via
+// `new RegExp` from a string constant (a template literal) so the control
+// character does not appear in a regex literal (no-control-regex).
+const MENTION_PLACEHOLDER_CHAR = "\u0001"
+const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)${MENTION_PLACEHOLDER_CHAR}`, "g")
+
+// mdast node types whose raw source regions must never be mention-rewritten:
+// code blocks (fenced or indented), inline code, links, images, raw HTML, math,
+// reference link definitions, and reference links/images all render as literal
+// or non-text content. Rewriting a definition's destination would corrupt the
+// reference link's href; rewriting a reference label or alt would leak the raw
+// placeholder into the anchor text or img alt (rehypeMentions skips anchors),
+// instead of producing a mention span.
+const MENTION_MASK_NODE_TYPES = new Set([
+ "code",
+ "inlineCode",
+ "link",
+ "image",
+ "html",
+ "inlineMath",
+ "math",
+ "definition",
+ "linkReference",
+ "imageReference",
+])
+
+/**
+ * Rewrite mention patterns in the RAW markdown string before remark tokenizes
+ * it, replacing each match with an indexed placeholder.
+ *
+ * Matching on remark's tokenized text nodes truncates paths that contain
+ * markdown-active characters: `@/src/__init__.py` is parsed as
+ * `@/src/` + init + `.py`, so per-node matching would only
+ * see `@/src/` and post the wrong path to `openMention`. Raw-string matching
+ * is also the behavior of the collapsed component, so this restores
+ * it for the expanded view.
+ *
+ * Matching runs on the raw string so the shared regex's boundary rules apply
+ * unchanged (replacing literal regions with spaces would turn a preceding `)`
+ * or backtick into whitespace and make non-mentions actionable). Literal / non-
+ * text regions (code, links, images, HTML, math, reference link definitions,
+ * and reference links/images) are marked via a throwaway mdast parse with the
+ * exact positions remark sees, and a match whose range intersects one of them
+ * is discarded so mentions inside such regions stay inert.
+ */
+function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } {
+ if (!markdown) {
+ return { preparedMarkdown: markdown, mentions: [] }
+ }
+
+ // A throwaway parse with the same extensions as the render pipeline, so the
+ // reported positions match what remark will tokenize. Mark literal and
+ // non-text regions (mdast positions carry absolute source offsets): a
+ // mention inside any of them must stay inert, because code and links render
+ // as literal/interactive content, images, raw HTML, and math keep their
+ // source text unchanged, a reference link definition's destination becomes
+ // the link's href, and a reference link/image label or alt renders as the
+ // anchor text or img alt (rewriting any of them would corrupt the href/alt
+ // or leak the raw placeholder into the rendered output).
+ const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown)
+
+ const isMasked = new Array(markdown.length).fill(false)
+ visit(tree, (node: any) => {
+ if (!MENTION_MASK_NODE_TYPES.has(node.type)) {
+ return
+ }
+ const start = node.position?.start?.offset
+ const end = node.position?.end?.offset
+ if (typeof start !== "number" || typeof end !== "number") {
+ return
+ }
+ for (let i = start; i < end && i < isMasked.length; i++) {
+ isMasked[i] = true
+ }
+ })
+
+ const mentions: string[] = []
+ let preparedMarkdown = ""
+ let lastIndex = 0
+ for (const match of markdown.matchAll(mentionRegexGlobal)) {
+ const start = match.index!
+ const end = start + match[0].length
+ // The raw string (not a masked copy) is what the shared regex's boundary
+ // rules must see: masking would turn a preceding `)` or backtick into a
+ // space and make a non-mention actionable (e.g. `[file](/src/a.ts)`@problems``).
+ // Discard a match only when its range lands inside a masked literal region.
+ if (isMasked.slice(start, end).some(Boolean)) {
+ continue
+ }
+ preparedMarkdown += markdown.slice(lastIndex, start)
+ mentions.push(markdown.slice(start, end))
+ preparedMarkdown += `${MENTION_PLACEHOLDER_CHAR}${mentions.length - 1}${MENTION_PLACEHOLDER_CHAR}`
+ lastIndex = end
+ }
+ preparedMarkdown += markdown.slice(lastIndex)
+
+ return { preparedMarkdown, mentions }
+}
+
+/**
+ * Rehype plugin that replaces the mention placeholders produced by
+ * prepareMentions with clickable spans matching the styling used by the
+ * collapsed Mention component.
+ */
+function rehypeMentions(mentions: string[]) {
+ return (tree: any) => {
+ visit(tree, "text", (node: any, index: number | undefined, parent: any) => {
+ if (index === undefined || !parent) {
+ return
+ }
+
+ // Skip text inside spans we already created (the visitor may revisit
+ // children inserted during the same pass).
+ if (parent?.tagName === "span" && parent.properties?.className?.includes("mention-context-highlight")) {
+ return
+ }
+
+ // prepareMentions already masks code and link regions, but keep these
+ // guards so the plugin stays safe on any tree: inside a role=button
+ // span would be invalid nested interactive content (WHATWG) and its
+ // stopPropagation would block the anchor's own openFile handler; inside
+ // code it would corrupt the CodeBlock text extraction, which only keeps
+ // string children (the mention text would silently disappear).
+ if (parent?.tagName === "code" || parent?.tagName === "pre" || parent?.tagName === "a") {
+ return
+ }
+
+ const originalValue = String(node.value)
+ const matches = Array.from(originalValue.matchAll(MENTION_PLACEHOLDER_REGEX))
+
+ if (matches.length === 0) {
+ return
+ }
+
+ // If any placeholder fails to resolve (should not happen), leave the
+ // text untouched instead of rendering the control characters verbatim.
+ if (matches.some((match) => mentions[Number(match[1])] === undefined)) {
+ return
+ }
+
+ const children: any[] = []
+ let lastIndex = 0
+
+ for (const match of matches) {
+ const mentionText = mentions[Number(match[1])]
+ // The raw mention includes the leading "@"; the posted value is the
+ // full path/word after it, matching the collapsed Mention component.
+ const mentionValue = mentionText.slice(1)
+ const mentionStart = match.index!
+
+ if (mentionStart > lastIndex) {
+ children.push({ type: "text", value: originalValue.slice(lastIndex, mentionStart) })
+ }
+
+ children.push({
+ type: "element",
+ tagName: "span",
+ properties: {
+ className: ["mention-context-highlight", "text-[0.9em]", "cursor-pointer"],
+ role: "button",
+ tabIndex: 0,
+ onClick: (event: React.MouseEvent) => {
+ // Keep mention clicks from bubbling to the TaskHeader toggle, which
+ // would collapse the expanded panel right after opening the mention.
+ event.stopPropagation()
+ vscode.postMessage({ type: "openMention", text: mentionValue })
+ },
+ // Keyboard parity with the click handler (a role=button span is not a
+ // native button, so Enter/Space must be handled explicitly).
+ // preventDefault keeps Space from also scrolling the expanded task panel,
+ // which otherwise receives the key's default action when a mention has
+ // focus.
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ vscode.postMessage({ type: "openMention", text: mentionValue })
+ },
+ },
+ children: [{ type: "text", value: mentionText }],
+ })
+
+ lastIndex = mentionStart + match[0].length
+ }
+
+ if (lastIndex < originalValue.length) {
+ children.push({ type: "text", value: originalValue.slice(lastIndex) })
+ }
+
+ parent.children.splice(index, 1, ...children)
+ })
+ }
+}
+
+/**
+ * Rehype plugin that drops the lone "\n" text node mdast-util-to-hast emits
+ * right after every
(its hardBreak handler returns [
, "\n"]).
+ *
+ * The paragraph styling in this webview uses `white-space: pre-wrap`, where a
+ * literal newline is significant. Without this, every remark-breaks
would
+ * be followed by an extra pre-wrap line break, inserting a blank line between
+ * each soft-broken line. Removing the node leaves exactly one line break per
+ * soft break, independent of CSS white-space handling.
+ */
+function rehypeStripBreakNewlines() {
+ return (tree: any) => {
+ visit(tree, "element", (node: any, index: number | undefined, parent: any) => {
+ if (node.tagName !== "br" || index === undefined || !parent) {
+ return
+ }
+ const next = parent.children[index + 1]
+ if (next?.type === "text" && next.value === "\n") {
+ parent.children.splice(index + 1, 1)
+ }
+ })
+ }
+}
+
// Codicon glyphs used as the leading icon for each GitHub-style alert type.
const ALERT_ICONS: Record = {
note: "codicon-info",
@@ -32,6 +259,22 @@ const ALERT_LABELS: Record = {
interface MarkdownBlockProps {
markdown?: string
+ /**
+ * Render context mentions (@/path, @problems, @terminal, ...) as clickable
+ * spans that post `openMention`. Off by default: mentions are only
+ * actionable where the text is user-authored (the expanded task prompt).
+ * Assistant-generated content (messages, reasoning, tool output, todos)
+ * keeps mention patterns as inert text.
+ */
+ mentions?: boolean
+ /**
+ * Render single newlines as
(remark-breaks) instead of collapsing them
+ * to spaces per CommonMark. Off by default so the shared pipeline keeps its
+ * CommonMark soft-break behavior for assistant-generated content. The
+ * expanded task prompt (user-authored text) enables it so plain multi-line
+ * prompts keep their line breaks while markdown still parses.
+ */
+ breaks?: boolean
}
const StyledMarkdown = styled.div`
@@ -273,7 +516,7 @@ const StyledMarkdown = styled.div`
}
`
-const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
+const MarkdownBlock = memo(({ markdown, mentions = false, breaks = false }: MarkdownBlockProps) => {
const components = useMemo(
() => ({
table: ({ children, ...props }: any) => {
@@ -394,6 +637,13 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
[],
)
+ // When mentions are actionable, rewrite the raw markdown before parsing so
+ // mention matching runs on the untokenized string (see prepareMentions).
+ const { preparedMarkdown, mentions: mentionList } = useMemo(
+ () => (mentions ? prepareMentions(markdown || "") : { preparedMarkdown: markdown || "", mentions: [] }),
+ [markdown, mentions],
+ )
+
return (
{
[remarkGfm, { singleTilde: false }],
remarkMath,
remarkGithubAlerts,
+ ...(breaks ? [remarkBreaks] : []),
() => {
return (tree: any) => {
visit(tree, "code", (node: any) => {
@@ -415,9 +666,13 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
}
},
]}
- rehypePlugins={[rehypeKatex as any]}
+ rehypePlugins={[
+ ...(mentions ? [[rehypeMentions, mentionList] as const] : []),
+ ...(breaks ? [rehypeStripBreakNewlines] : []),
+ rehypeKatex as any,
+ ]}
components={components}>
- {markdown || ""}
+ {preparedMarkdown}
)
diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
index 2c56fc418a..5091f20d6d 100644
--- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
+++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
@@ -1,13 +1,21 @@
-import { render, screen } from "@/utils/test-utils"
+import { render, screen, fireEvent } from "@/utils/test-utils"
import MarkdownBlock from "../MarkdownBlock"
+const { mockPostMessage } = vi.hoisted(() => ({
+ mockPostMessage: vi.fn(),
+}))
+
vi.mock("@src/utils/vscode", () => ({
vscode: {
- postMessage: vi.fn(),
+ postMessage: mockPostMessage,
},
}))
+beforeEach(() => {
+ mockPostMessage.mockClear()
+})
+
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
theme: "dark",
@@ -217,4 +225,403 @@ describe("MarkdownBlock", () => {
expect(screen.getByText("Third level ordered")).toBeInTheDocument()
expect(screen.getByText("Back to first level")).toBeInTheDocument()
})
+
+ describe("Context mentions (#559)", () => {
+ it("keeps mention patterns inert when the mentions prop is not set", async () => {
+ // Mentions are only actionable where text is user-authored. Assistant
+ // content rendered through the default MarkdownBlock must keep mention
+ // patterns as plain, non-interactive text.
+ const markdown = "Check @/src/file.ts and @problems."
+ const { container } = render( )
+
+ await screen.findByText(/Check/, { exact: false })
+
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ expect(container.querySelector("p")?.textContent).toBe("Check @/src/file.ts and @problems.")
+ })
+
+ it("renders @/path/file.ts as a clickable mention span", async () => {
+ const markdown = "Check out @/src/components/chat/TaskHeader.tsx for details."
+ const { container } = render( )
+
+ await screen.findByText(/Check out/, { exact: false })
+
+ // The mention should be wrapped in a span with the mention-context-highlight class.
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@/src/components/chat/TaskHeader.tsx")
+
+ // The trailing period must remain outside the mention span.
+ expect(container.querySelector("p")?.textContent).toBe(
+ "Check out @/src/components/chat/TaskHeader.tsx for details.",
+ )
+ })
+
+ it("renders @problems as a clickable mention span", async () => {
+ const markdown = "Review the issues listed in @problems before proceeding."
+ const { container } = render( )
+
+ await screen.findByText(/Review/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@problems")
+ })
+
+ it("renders @terminal as a clickable mention span", async () => {
+ const markdown = "See the output captured in @terminal."
+ const { container } = render( )
+
+ await screen.findByText(/See/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@terminal")
+ })
+
+ it("renders multiple mentions in the same paragraph", async () => {
+ const markdown = "Check @/src/file.ts and @problems, then review @terminal."
+ const { container } = render( )
+
+ await screen.findByText(/Check/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(3)
+ expect(mentions[0].textContent).toBe("@/src/file.ts")
+ expect(mentions[1].textContent).toBe("@problems")
+ expect(mentions[2].textContent).toBe("@terminal")
+ })
+
+ it("posts openMention message when a mention span is clicked", async () => {
+ const markdown = "See @/src/components/chat/TaskHeader.tsx."
+ const { container } = render( )
+
+ await screen.findByText(/See/, { exact: false })
+
+ const mentionSpan = container.querySelector("span.mention-context-highlight")!
+ fireEvent.click(mentionSpan)
+
+ expect(mockPostMessage).toHaveBeenCalledWith({
+ type: "openMention",
+ text: "/src/components/chat/TaskHeader.tsx",
+ })
+ })
+
+ it("does not match @ in the middle of a word or log entry", async () => {
+ const markdown = "Error: Failed@localhost/status code 404."
+ const { container } = render( )
+
+ await screen.findByText(/Error/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(0)
+ })
+
+ it("keeps mention patterns literal inside fenced code blocks", async () => {
+ const markdown = "```bash\necho hello @problems\n```"
+ const { container } = render( )
+
+ await screen.findByText(/echo/, { exact: false })
+
+ // Code is literal content: the mention must stay plain text, not become a
+ // clickable span (which would also make the text vanish from CodeBlock).
+ expect(container.querySelector("code")?.textContent).toBe("echo hello @problems\n")
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ })
+
+ it("keeps mention patterns literal inside inline code", async () => {
+ const markdown = "Use `@problems` carefully."
+ const { container } = render( )
+
+ await screen.findByText(/Use/, { exact: false })
+
+ expect(container.querySelector("code")?.textContent).toBe("@problems")
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ })
+
+ it("keeps mention patterns literal inside link text even when enabled", async () => {
+ // A mention inside must not become a nested role=button span: that
+ // is invalid interactive content (WHATWG) and would block the anchor's
+ // own openFile handler via stopPropagation.
+ const markdown = "see [open @/src/main.ts](/src/main.ts) please"
+ const { container } = render( )
+
+ await screen.findByText(/please/, { exact: false })
+
+ const anchor = container.querySelector("a")!
+ expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0)
+ expect(anchor.textContent).toBe("open @/src/main.ts")
+
+ // The anchor's own handler still fires (nothing swallows the click).
+ fireEvent.click(anchor)
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "/src/main.ts" })
+ expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" }))
+ })
+
+ it("keeps reference link destinations inert and preserves the original href", async () => {
+ // A reference definition's destination renders as the reference link's
+ // href, so rewriting it to a mention placeholder would corrupt the href
+ // (control characters instead of the original path) rather than produce
+ // a mention span. The whole definition region must stay masked.
+ const markdown = "[docs]: @/docs/readme.md\n\nSee [docs] and @problems."
+ const { container } = render( )
+
+ await screen.findByText(/See/, { exact: false })
+
+ // The reference link keeps its original href, untouched by mention
+ // preprocessing, and the destination never becomes a mention span.
+ const anchor = container.querySelector("a")!
+ expect(anchor).toHaveAttribute("href", "@/docs/readme.md")
+ expect(anchor.textContent).toBe("docs")
+ expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0)
+
+ // Masking the definition must not affect a real mention in the body.
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@problems")
+
+ // No placeholder control characters leak into the rendered output.
+ expect(container.textContent).not.toContain("\u0001")
+ expect(anchor.getAttribute("href")).not.toContain("\u0001")
+ })
+
+ it("keeps reference link labels inert and preserves the label text", async () => {
+ // A mention inside a reference link's label renders as the anchor's
+ // text. Rewriting it to a mention placeholder would leak the raw
+ // placeholder (rehypeMentions skips anchors, so the control characters
+ // would render verbatim inside the link) and a role=button span inside
+ // would be invalid nested interactive content. The whole reference
+ // region must stay masked.
+ const markdown = "See [the @problems summary][docs] now.\n\n[docs]: https://example.com/problems"
+ const { container } = render( )
+
+ await screen.findByText(/now/, { exact: false })
+
+ const anchor = container.querySelector("a")!
+ expect(anchor).toHaveAttribute("href", "https://example.com/problems")
+ expect(anchor.textContent).toBe("the @problems summary")
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+
+ // No placeholder control characters leak into the rendered output.
+ expect(container.textContent).not.toContain("\u0001")
+ })
+
+ it("keeps image reference alt text inert and preserves the alt attribute", async () => {
+ // An image reference's alt renders as the img's alt attribute. Rewriting
+ // it to a mention placeholder would corrupt the alt instead of producing
+ // a mention span. The whole reference region must stay masked.
+ const markdown = "See ![a @problems screenshot][docs] now.\n\n[docs]: https://example.com/problems.png"
+ const { container } = render( )
+
+ await screen.findByText(/now/, { exact: false })
+
+ const img = container.querySelector("img")!
+ expect(img).toHaveAttribute("src", "https://example.com/problems.png")
+ expect(img).toHaveAttribute("alt", "a @problems screenshot")
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ expect(container.textContent).not.toContain("\u0001")
+ })
+
+ it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => {
+ // Matching must run on the raw string so the shared regex's start boundary
+ // sees the real characters: with no whitespace after a closing `)` or a
+ // backtick, `@problems` is not a mention (the collapsed
+ // component rejects it too). Replacing the literal regions with spaces
+ // before matching would make them actionable.
+ const markdown = "[file](/src/a.ts)@problems and `x`@problems"
+ const { container } = render( )
+
+ // The anchor text is a stable, unique wait target.
+ await screen.findByText("file")
+
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ // The text still renders verbatim (link + plain text, no spans).
+ expect(container.querySelector("p")?.textContent).toBe("file@problems and x@problems")
+ })
+
+ it("keeps a whitespace-separated mention after a link or inline code actionable", async () => {
+ // The space is a legitimate boundary for the shared regex, so these
+ // mentions stay clickable: the masked regions end before the spaces and
+ // the match ranges do not intersect them.
+ const markdown = "[file](/src/a.ts) @problems and `x` @problems"
+ const { container } = render( )
+
+ // The anchor text is a stable, unique wait target.
+ await screen.findByText("file")
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions).toHaveLength(2)
+ expect(mentions[0].textContent).toBe("@problems")
+ expect(mentions[1].textContent).toBe("@problems")
+
+ fireEvent.click(mentions[0])
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "problems" })
+ })
+
+ it("renders a standalone mention with no surrounding text", async () => {
+ // A mention that both starts and ends the text node exercises the
+ // no-leading-text and no-trailing-text branches of the splitter.
+ const markdown = "@problems"
+ const { container } = render( )
+
+ await screen.findByText("@problems")
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@problems")
+
+ // No leading/trailing text: the paragraph is exactly the mention.
+ expect(container.querySelector("p")?.textContent).toBe("@problems")
+ })
+
+ it("makes mentions keyboard operable (role=button, tabIndex, Enter/Space)", async () => {
+ const markdown = "See @terminal."
+ const { container } = render( )
+
+ await screen.findByText(/See/, { exact: false })
+
+ const mention = container.querySelector("span.mention-context-highlight")!
+ expect(mention.getAttribute("role")).toBe("button")
+ expect(mention.getAttribute("tabindex")).toBe("0")
+
+ // Enter/Space must both post and be default-prevented: dispatching a
+ // cancelable event returns false once preventDefault has run, and Space's
+ // default action would otherwise scroll the expanded task panel while a
+ // mention has focus.
+ expect(fireEvent.keyDown(mention, { key: "Enter" })).toBe(false)
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" })
+
+ mockPostMessage.mockClear()
+ expect(fireEvent.keyDown(mention, { key: " " })).toBe(false)
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" })
+
+ mockPostMessage.mockClear()
+ // An unrelated key neither posts nor prevents the default action.
+ expect(fireEvent.keyDown(mention, { key: "a" })).toBe(true)
+ expect(mockPostMessage).not.toHaveBeenCalled()
+ })
+
+ it("preserves regular text around mentions", async () => {
+ const markdown = "Before @problems middle after"
+ const { container } = render( )
+
+ await screen.findByText(/Before/, { exact: false })
+
+ const paragraph = container.querySelector("p")
+ expect(paragraph?.textContent).toBe("Before @problems middle after")
+
+ // The mention span should only contain the mention itself.
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@problems")
+ })
+
+ it("matches the full mention path when it contains markdown-active characters", async () => {
+ // remark tokenizes `@/src/__init__.py` as `@/src/` + init + `.py`,
+ // so matching on tokenized text nodes would truncate the mention to `@/src/` and
+ // post the wrong path. Mention matching must run on the raw string instead.
+ const markdown = "Run the tests for @/src/__init__.py now."
+ const { container } = render( )
+
+ await screen.findByText(/Run the tests/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@/src/__init__.py")
+
+ // No stray for the `__init__` part: the whole path is one mention.
+ expect(container.querySelector("p")?.querySelector("strong")).toBeNull()
+
+ // Clicking must post the FULL path, not the truncated `@/src/` prefix.
+ fireEvent.click(mentions[0])
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/__init__.py" })
+ })
+
+ it("matches mentions containing asterisks on the raw string", async () => {
+ // `*files*` would tokenize as emphasis, splitting the path across text nodes.
+ const markdown = "Check @/src/*files* before shipping."
+ const { container } = render( )
+
+ await screen.findByText(/Check/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions.length).toBe(1)
+ expect(mentions[0].textContent).toBe("@/src/*files*")
+
+ fireEvent.click(mentions[0])
+ expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/*files*" })
+ })
+
+ it("resolves mentions and line breaks together in the same prompt", async () => {
+ // The raw-string mention preprocessing and remark-breaks both rewrite the
+ // paragraph; they must compose: the mention stays a single span and the
+ // soft break between the lines renders as one
.
+ const markdown = "Check @/src/file.ts\nthen review @problems"
+ const { container } = render( )
+
+ await screen.findByText(/then review/, { exact: false })
+
+ const mentions = container.querySelectorAll("span.mention-context-highlight")
+ expect(mentions).toHaveLength(2)
+ expect(mentions[0].textContent).toBe("@/src/file.ts")
+ expect(mentions[1].textContent).toBe("@problems")
+
+ const paragraph = container.querySelector("p")
+ expect(paragraph?.querySelectorAll("br")).toHaveLength(1)
+ expect(paragraph?.textContent).toBe("Check @/src/file.tsthen review @problems")
+ })
+
+ it("keeps placeholder-free output when the prompt contains no mentions", async () => {
+ // Preprocessing must not leak placeholder control characters into rendered
+ // text when the (raw) text happens to contain mention-like patterns that do
+ // not match (e.g. @ not preceded by whitespace).
+ const markdown = "Failed@localhost/status code 404."
+ const { container } = render( )
+
+ await screen.findByText(/Failed/, { exact: false })
+
+ expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0)
+ expect(container.querySelector("p")?.textContent).toBe("Failed@localhost/status code 404.")
+ })
+ })
+
+ describe("line breaks (breaks prop)", () => {
+ it("renders a soft line break as
when breaks is set", async () => {
+ const markdown = "line one\nline two"
+ const { container } = render( )
+
+ await screen.findByText(/line one/)
+
+ const paragraph = container.querySelector("p")
+ expect(paragraph).not.toBeNull()
+ // remark-breaks turns the single newline into a real
so the line
+ // break is structural instead of relying on CSS white-space.
+ expect(paragraph?.querySelector("br")).not.toBeNull()
+ expect(paragraph?.textContent).toBe("line oneline two")
+ })
+
+ it("keeps soft line breaks as text by default", async () => {
+ const markdown = "line one\nline two"
+ const { container } = render( )
+
+ // The text matcher must be inexact: by default the newline stays inside
+ // the single text node, so "line one" is not a standalone node.
+ await screen.findByText(/line one/, { exact: false })
+
+ const paragraph = container.querySelector("p")
+ expect(paragraph?.querySelector("br")).toBeNull()
+ expect(paragraph?.textContent).toBe("line one\nline two")
+ })
+
+ it("keeps blank lines as paragraph breaks when breaks is set", async () => {
+ const markdown = "first paragraph\n\nsecond paragraph"
+ const { container } = render( )
+
+ await screen.findByText("first paragraph")
+
+ // Two separate paragraphs (the blank line is a hard break, not a soft one).
+ expect(container.querySelectorAll("p")).toHaveLength(2)
+ expect(container.querySelector("p")?.querySelector("br")).toBeNull()
+ })
+ })
})