From f71deeb04a9457d818fdc941dab16fde24764c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:17:48 -0300 Subject: [PATCH] feat: linkify file paths inside fenced code blocks (#1560) Extends existing file-reference detection (currently only inline code and anchor tags) to also wrap path-like tokens inside
blocks,
making shell-output paths like 'src/foo.ts:42' clickable just like
inline ones.
Implementation in MarkdownRendererImpl.tsx:
- New regex BLOCK_PATH_TOKEN_RE matches paths with mandatory extension
and optional :line[:col] suffix.
- New helper wrapBlockCodePathTokens() walks text nodes in each
rendered , wraps matches in
, and marks the
block as scanned (data-openchamber-block-paths-scanned) to avoid
re-walking.
- annotateFileLinks() invokes the wrapper and extends its selector
to include the new tokens; the rest of the pipeline (stat check,
click handler) is reused unchanged.
- Code blocks longer than 200KB are skipped to keep large outputs
(git log, build logs) cheap.
---
.../components/chat/MarkdownRendererImpl.tsx | 111 +++++++++++++++++-
1 file changed, 110 insertions(+), 1 deletion(-)
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)) {