diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 7edd7bb5..fc8eddb1 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1102,6 +1102,20 @@ interface MarkdownRendererProps { const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; +const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; +const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; +const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned'; +// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file +// extension (1-8 alphanumerics) so plain words don't qualify; the path itself +// must contain at least one extension-bearing segment. +// +// Known limitation: backslash-separated Windows paths (e.g. +// `C:\Users\test\file.ts:12`) are not matched because the path character class +// does not include `\`. Compiler output inside fenced code blocks predominantly +// uses forward slashes, so this is a niche gap. The inline-code pipeline is not +// affected — it reads full text content rather than matching with a regex. +const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g; +const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CACHE_MAX = 1000; const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200; @@ -1304,6 +1318,96 @@ const extractPathCandidateFromElement = (element: HTMLElement): string => { return (element.textContent || '').trim(); }; +// Walks text nodes inside `
` subtrees and wraps any substring that
+// looks like a `path[:line[:col]]` reference in a span carrying
+// `data-openchamber-block-path-token`. `annotateFileLinks` then promotes those
+// spans into clickable file links via the same existing pipeline used for
+// inline code (parseFileReference → fileReferenceExists → openFileReference).
+//
+// Idempotent: each `` node is marked with
+// `data-openchamber-block-paths-scanned` once processed so the walk is not
+// repeated on the same element. When the renderer replaces the `` subtree
+// (e.g. on content change during streaming), the new element lacks the marker and
+// will be rescanned on the next mutation-observer callback.
+const wrapBlockCodePathTokens = (container: HTMLElement): void => {
+  const codeBlocks = container.querySelectorAll('pre code');
+  if (codeBlocks.length === 0) {
+    return;
+  }
+
+  const doc = container.ownerDocument;
+  if (!doc) {
+    return;
+  }
+
+  for (const codeBlock of Array.from(codeBlocks)) {
+    if (codeBlock.getAttribute(CODE_BLOCK_PATH_SCANNED_ATTR) === 'true') {
+      continue;
+    }
+
+    // Skip absurdly large code blocks to keep DOM work bounded.
+    if ((codeBlock.textContent ?? '').length > MAX_BLOCK_CODE_SCAN_LENGTH) {
+      codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
+      continue;
+    }
+
+    const walker = doc.createTreeWalker(codeBlock, NodeFilter.SHOW_TEXT);
+    const textNodes: Text[] = [];
+    let currentNode = walker.nextNode();
+    while (currentNode) {
+      textNodes.push(currentNode as Text);
+      currentNode = walker.nextNode();
+    }
+
+    for (const textNode of textNodes) {
+      // Skip nodes already inside one of our token spans.
+      if (textNode.parentElement?.closest(BLOCK_PATH_TOKEN_SELECTOR)) {
+        continue;
+      }
+
+      const text = textNode.data;
+      if (!text || !text.includes('.')) {
+        continue;
+      }
+
+      BLOCK_PATH_TOKEN_RE.lastIndex = 0;
+      const matches: Array<{ start: number; end: number; raw: string }> = [];
+      let match: RegExpExecArray | null = BLOCK_PATH_TOKEN_RE.exec(text);
+      while (match) {
+        const raw = match[0];
+        if (raw && isLikelyFilePath(raw)) {
+          matches.push({ start: match.index, end: match.index + raw.length, raw });
+        }
+        match = BLOCK_PATH_TOKEN_RE.exec(text);
+      }
+
+      if (matches.length === 0) {
+        continue;
+      }
+
+      const fragment = doc.createDocumentFragment();
+      let cursor = 0;
+      for (const { start, end, raw } of matches) {
+        if (start > cursor) {
+          fragment.appendChild(doc.createTextNode(text.slice(cursor, start)));
+        }
+        const span = doc.createElement('span');
+        span.setAttribute(BLOCK_PATH_TOKEN_ATTR, 'true');
+        span.textContent = raw;
+        fragment.appendChild(span);
+        cursor = end;
+      }
+      if (cursor < text.length) {
+        fragment.appendChild(doc.createTextNode(text.slice(cursor)));
+      }
+
+      textNode.parentNode?.replaceChild(fragment, textNode);
+    }
+
+    codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
+  }
+};
+
 const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
   const parsed = parseFileReference(rawValue);
   if (!parsed || !isLikelyFilePathValue(parsed.path)) {
@@ -1424,7 +1528,12 @@ const useFileReferenceInteractions = ({
     }
 
     const annotateFileLinks = () => {
-      const candidates = container.querySelectorAll('[data-markdown="inline-code"], a');
+      if (enabled) {
+        wrapBlockCodePathTokens(container);
+      }
+      const candidates = container.querySelectorAll(
+        `[data-markdown="inline-code"], a, ${BLOCK_PATH_TOKEN_SELECTOR}`,
+      );
       let linkedCount = 0;
 
       for (const candidate of Array.from(candidates)) {