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 (
);
}
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;
+ });
+ };
+}