Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/features/layout/hooks/useLayoutNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult {
}
processingStartedAt={activeThreadStatus?.processingStartedAt ?? null}
lastDurationMs={activeThreadStatus?.lastDurationMs ?? null}
workspacePath={options.activeWorkspace?.path ?? null}
/>
);

Expand Down
51 changes: 49 additions & 2 deletions src/features/messages/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={
onOpenFileLink
? {
a: ({ href, children, ...props }) => {
const target = href ? parseFileLinkUrl(href) : null;
if (target) {
return (
<a
href={href}
{...props}
onClick={(event) => {
event.preventDefault();
onOpenFileLink(target.path, target.line, target.column);
}}
>
{children}
</a>
);
}
return (
<a href={href} {...props}>
{children}
</a>
);
},
}
: undefined
}
>
{content}
</ReactMarkdown>
</div>
);
}
24 changes: 21 additions & 3 deletions src/features/messages/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -11,6 +12,7 @@ type MessagesProps = {
isThinking: boolean;
processingStartedAt?: number | null;
lastDurationMs?: number | null;
workspacePath?: string | null;
};

type ToolSummary = {
Expand Down Expand Up @@ -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<HTMLDivElement | null>(null);
Expand All @@ -217,6 +220,7 @@ export const Messages = memo(function Messages({
const copyTimeoutRef = useRef<number | null>(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;
Expand Down Expand Up @@ -334,7 +338,11 @@ export const Messages = memo(function Messages({
return (
<div key={item.id} className={`message ${item.role}`}>
<div className="bubble message-bubble">
<Markdown value={item.text} className="markdown" />
<Markdown
value={item.text}
className="markdown"
onOpenFileLink={handleOpenFileLink}
/>
<button
type="button"
className={`ghost message-copy-button${isCopied ? " is-copied" : ""}`}
Expand Down Expand Up @@ -404,6 +412,7 @@ export const Messages = memo(function Messages({
className={`reasoning-inline-detail markdown ${
isExpanded ? "" : "tool-inline-clamp"
}`}
onOpenFileLink={handleOpenFileLink}
/>
)}
</div>
Expand All @@ -426,7 +435,11 @@ export const Messages = memo(function Messages({
</span>
</div>
{item.text && (
<Markdown value={item.text} className="item-text markdown" />
<Markdown
value={item.text}
className="item-text markdown"
onOpenFileLink={handleOpenFileLink}
/>
)}
</div>
);
Expand Down Expand Up @@ -557,13 +570,18 @@ export const Messages = memo(function Messages({
</div>
)}
{isExpanded && isFileChange && !hasChanges && item.detail && (
<Markdown value={item.detail} className="item-text markdown" />
<Markdown
value={item.detail}
className="item-text markdown"
onOpenFileLink={handleOpenFileLink}
/>
)}
{showToolOutput && summary.output && (
<Markdown
value={summary.output}
className="tool-inline-output markdown"
codeBlock
onOpenFileLink={handleOpenFileLink}
/>
)}
</div>
Expand Down
38 changes: 38 additions & 0 deletions src/features/messages/hooks/useFileLinkOpener.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

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],
);
}
73 changes: 73 additions & 0 deletions src/utils/fileLinks.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
77 changes: 77 additions & 0 deletions src/utils/remarkFileLinks.ts
Original file line number Diff line number Diff line change
@@ -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" ||

Check failure on line 27 in src/utils/remarkFileLinks.ts

View workflow job for this annotation

GitHub Actions / build-macos

This comparison appears to be unintentional because the types '"heading" | "root" | "delete" | "emphasis" | "paragraph" | "strong" | "tableCell"' and '"inlineCode"' have no overlap.
parent.type === "code"

Check failure on line 28 in src/utils/remarkFileLinks.ts

View workflow job for this annotation

GitHub Actions / build-macos

This comparison appears to be unintentional because the types '"heading" | "root" | "delete" | "emphasis" | "paragraph" | "strong" | "tableCell"' and '"code"' have no overlap.
) {
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;
});
};
}
Loading