From 3c622207ea7d7413ed7d6ce4923f94ea8d761c9d Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Fri, 16 Jan 2026 12:17:00 +0100 Subject: [PATCH] Add clickable file paths in messages --- package-lock.json | 3 +- package.json | 5 +- src/features/layout/hooks/useLayoutNodes.tsx | 1 + src/features/messages/components/Markdown.tsx | 51 +++++++++++- src/features/messages/components/Messages.tsx | 24 +++++- .../messages/hooks/useFileLinkOpener.ts | 38 +++++++++ src/utils/fileLinks.ts | 73 ++++++++++++++++++ src/utils/remarkFileLinks.ts | 77 +++++++++++++++++++ 8 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 src/features/messages/hooks/useFileLinkOpener.ts create mode 100644 src/utils/fileLinks.ts create mode 100644 src/utils/remarkFileLinks.ts diff --git a/package-lock.json b/package-lock.json index 93822e8b9..51c73c857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/package.json b/package.json index 35ae3f765..0b8235e96 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "lucide-react": "^0.562.0", "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "@xterm/xterm": "^5.5.0", - "@xterm/addon-fit": "^0.10.0" + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 0371f9b48..cb3b4ed3b 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -263,6 +263,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { } processingStartedAt={activeThreadStatus?.processingStartedAt ?? null} lastDurationMs={activeThreadStatus?.lastDurationMs ?? null} + workspacePath={options.activeWorkspace?.path ?? null} /> ); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index b10d173bd..6ab2842bf 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -1,17 +1,64 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { parseFileLinkUrl } from "../../../utils/fileLinks"; +import { remarkFileLinks } from "../../../utils/remarkFileLinks"; type MarkdownProps = { value: string; className?: string; codeBlock?: boolean; + onOpenFileLink?: ( + path: string, + line?: number | null, + column?: number | null, + ) => void; }; -export function Markdown({ value, className, codeBlock }: MarkdownProps) { +export function Markdown({ + value, + className, + codeBlock, + onOpenFileLink, +}: MarkdownProps) { const content = codeBlock ? `\`\`\`\n${value}\n\`\`\`` : value; + const remarkPlugins = onOpenFileLink + ? [remarkGfm, remarkFileLinks] + : [remarkGfm]; return (
- {content} + { + const target = href ? parseFileLinkUrl(href) : null; + if (target) { + return ( + { + event.preventDefault(); + onOpenFileLink(target.path, target.line, target.column); + }} + > + {children} + + ); + } + return ( + + {children} + + ); + }, + } + : undefined + } + > + {content} +
); } diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx index 079620cca..4f401906e 100644 --- a/src/features/messages/components/Messages.tsx +++ b/src/features/messages/components/Messages.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import type { ConversationItem } from "../../../types"; import { Markdown } from "./Markdown"; +import { useFileLinkOpener } from "../hooks/useFileLinkOpener"; import { DiffBlock } from "../../git/components/DiffBlock"; import { languageFromPath } from "../../../utils/syntax"; @@ -11,6 +12,7 @@ type MessagesProps = { isThinking: boolean; processingStartedAt?: number | null; lastDurationMs?: number | null; + workspacePath?: string | null; }; type ToolSummary = { @@ -207,6 +209,7 @@ export const Messages = memo(function Messages({ isThinking, processingStartedAt = null, lastDurationMs = null, + workspacePath = null, }: MessagesProps) { const SCROLL_THRESHOLD_PX = 120; const bottomRef = useRef(null); @@ -217,6 +220,7 @@ export const Messages = memo(function Messages({ const copyTimeoutRef = useRef(null); const [elapsedMs, setElapsedMs] = useState(0); const scrollKey = scrollKeyForItems(items); + const handleOpenFileLink = useFileLinkOpener(workspacePath); const isNearBottom = (node: HTMLDivElement) => node.scrollHeight - node.scrollTop - node.clientHeight <= SCROLL_THRESHOLD_PX; @@ -334,7 +338,11 @@ export const Messages = memo(function Messages({ return (
- +
@@ -426,7 +435,11 @@ export const Messages = memo(function Messages({
{item.text && ( - + )} ); @@ -557,13 +570,18 @@ export const Messages = memo(function Messages({ )} {isExpanded && isFileChange && !hasChanges && item.detail && ( - + )} {showToolOutput && summary.output && ( )} diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts new file mode 100644 index 000000000..ce1322113 --- /dev/null +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { resolve } from "@tauri-apps/api/path"; +import { openPath } from "@tauri-apps/plugin-opener"; +import { isAbsolutePath } from "../../../utils/fileLinks"; + +type FileLinkHandler = ( + path: string, + line?: number | null, + column?: number | null, +) => Promise; + +export function useFileLinkOpener(basePath?: string | null): FileLinkHandler { + return useCallback( + async (path: string) => { + if (!path) { + return; + } + const trimmed = path.trim(); + if (!trimmed) { + return; + } + let resolvedPath = trimmed; + if (!isAbsolutePath(trimmed) && basePath) { + try { + resolvedPath = await resolve(basePath, trimmed); + } catch { + resolvedPath = trimmed; + } + } + try { + await openPath(resolvedPath); + } catch { + // Ignore opener failures to avoid breaking message rendering. + } + }, + [basePath], + ); +} diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts new file mode 100644 index 000000000..0dbf3022c --- /dev/null +++ b/src/utils/fileLinks.ts @@ -0,0 +1,73 @@ +export type FileLinkTarget = { + path: string; + line?: number | null; + column?: number | null; +}; + +const FILE_LINK_PROTOCOL = "codex-file:"; +const FILE_LINK_HOST = "open"; + +export function buildFileLinkUrl(target: FileLinkTarget) { + const params = new URLSearchParams({ path: target.path }); + if (target.line) { + params.set("line", String(target.line)); + } + if (target.column) { + params.set("column", String(target.column)); + } + return `${FILE_LINK_PROTOCOL}//${FILE_LINK_HOST}?${params.toString()}`; +} + +export function parseFileLinkUrl(href: string): FileLinkTarget | null { + try { + const url = new URL(href); + if (url.protocol !== FILE_LINK_PROTOCOL || url.host !== FILE_LINK_HOST) { + return null; + } + const path = url.searchParams.get("path"); + if (!path) { + return null; + } + const lineRaw = url.searchParams.get("line"); + const columnRaw = url.searchParams.get("column"); + const line = lineRaw ? Number(lineRaw) : null; + const column = columnRaw ? Number(columnRaw) : null; + return { + path, + line: Number.isFinite(line) ? line : null, + column: Number.isFinite(column) ? column : null, + }; + } catch { + return null; + } +} + +export function splitFilePathMatch(raw: string): FileLinkTarget { + const hashMatch = raw.match(/^(.*)#L(\d+)(?::(\d+))?$/); + if (hashMatch) { + return { + path: hashMatch[1], + line: Number(hashMatch[2]), + column: hashMatch[3] ? Number(hashMatch[3]) : null, + }; + } + + const colonMatch = raw.match(/^(.*?)(?::(\d+))(?:[:](\d+))?$/); + if (colonMatch) { + return { + path: colonMatch[1], + line: Number(colonMatch[2]), + column: colonMatch[3] ? Number(colonMatch[3]) : null, + }; + } + + return { path: raw }; +} + +export function isAbsolutePath(path: string) { + return ( + path.startsWith("/") || + path.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(path) + ); +} diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts new file mode 100644 index 000000000..e284b08bd --- /dev/null +++ b/src/utils/remarkFileLinks.ts @@ -0,0 +1,77 @@ +import type { Root, PhrasingContent } from "mdast"; +import { visit } from "unist-util-visit"; +import { buildFileLinkUrl, splitFilePathMatch } from "./fileLinks"; + +const filePathPattern = + /(?:~\/|\.\.\/|\.\/|\/|[A-Za-z]:[\\/])?[^\s<>`"\]\[()]+(?:[\\/][^\s<>`"\]\[()]+)+(?:(?:#L|:)(?:\d+)(?::\d+)?)?/g; + +function trimTrailingPunctuation(raw: string) { + let trimmed = raw; + let trailing = ""; + while (/[),;!?]$/.test(trimmed)) { + trailing = trimmed.slice(-1) + trailing; + trimmed = trimmed.slice(0, -1); + } + return { trimmed, trailing }; +} + +export function remarkFileLinks() { + return (tree: Root) => { + visit(tree, "text", (node, index, parent) => { + if (!parent || typeof index !== "number") { + return; + } + if ( + parent.type === "link" || + parent.type === "linkReference" || + parent.type === "inlineCode" || + parent.type === "code" + ) { + return; + } + const value = node.value; + if (!value || !filePathPattern.test(value)) { + return; + } + filePathPattern.lastIndex = 0; + const parts: PhrasingContent[] = []; + let lastIndex = 0; + for (const match of value.matchAll(filePathPattern)) { + if (match.index === undefined) { + continue; + } + const start = match.index; + if (start > lastIndex) { + parts.push({ type: "text", value: value.slice(lastIndex, start) }); + } + const rawMatch = match[0]; + if (rawMatch.includes("://")) { + parts.push({ type: "text", value: rawMatch }); + lastIndex = start + rawMatch.length; + continue; + } + const { trimmed, trailing } = trimTrailingPunctuation(rawMatch); + if (!trimmed) { + parts.push({ type: "text", value: rawMatch }); + lastIndex = start + rawMatch.length; + continue; + } + const target = splitFilePathMatch(trimmed); + parts.push({ + type: "link", + url: buildFileLinkUrl(target), + children: [{ type: "text", value: trimmed }], + }); + if (trailing) { + parts.push({ type: "text", value: trailing }); + } + lastIndex = start + rawMatch.length; + } + if (lastIndex < value.length) { + parts.push({ type: "text", value: value.slice(lastIndex) }); + } + parent.children.splice(index, 1, ...parts); + return index + parts.length; + }); + }; +}