diff --git a/codex-cli/src/app.tsx b/codex-cli/src/app.tsx
index 3f84935c..fb02fb44 100644
--- a/codex-cli/src/app.tsx
+++ b/codex-cli/src/app.tsx
@@ -50,6 +50,7 @@ export default function App({
);
}
diff --git a/codex-cli/src/components/chat/message-history.tsx b/codex-cli/src/components/chat/message-history.tsx
index 79a173c2..bab6b166 100644
--- a/codex-cli/src/components/chat/message-history.tsx
+++ b/codex-cli/src/components/chat/message-history.tsx
@@ -1,6 +1,7 @@
import type { TerminalHeaderProps } from "./terminal-header.js";
import type { GroupedResponseItem } from "./use-message-grouping.js";
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
+import type { FileOpenerScheme } from "src/utils/config.js";
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
import TerminalHeader from "./terminal-header.js";
@@ -19,11 +20,13 @@ type MessageHistoryProps = {
confirmationPrompt: React.ReactNode;
loading: boolean;
headerProps: TerminalHeaderProps;
+ fileOpener: FileOpenerScheme | undefined;
};
const MessageHistory: React.FC = ({
batch,
headerProps,
+ fileOpener,
}) => {
const messages = batch.map(({ item }) => item!);
@@ -68,7 +71,10 @@ const MessageHistory: React.FC = ({
message.type === "message" && message.role === "user" ? 0 : 1
}
>
-
+
);
}}
diff --git a/codex-cli/src/components/chat/terminal-chat-past-rollout.tsx b/codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
index f041f36f..1ac8280e 100644
--- a/codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
+++ b/codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
@@ -1,5 +1,6 @@
import type { TerminalChatSession } from "../../utils/session.js";
import type { ResponseItem } from "openai/resources/responses/responses";
+import type { FileOpenerScheme } from "src/utils/config.js";
import TerminalChatResponseItem from "./terminal-chat-response-item";
import { Box, Text } from "ink";
@@ -8,9 +9,11 @@ import React from "react";
export default function TerminalChatPastRollout({
session,
items,
+ fileOpener,
}: {
session: TerminalChatSession;
items: Array;
+ fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement {
const { version, id: sessionId, model } = session;
return (
@@ -51,9 +54,13 @@ export default function TerminalChatPastRollout({
{React.useMemo(
() =>
items.map((item, key) => (
-
+
)),
- [items],
+ [items, fileOpener],
)}
diff --git a/codex-cli/src/components/chat/terminal-chat-response-item.tsx b/codex-cli/src/components/chat/terminal-chat-response-item.tsx
index 5ca53ac3..90c188aa 100644
--- a/codex-cli/src/components/chat/terminal-chat-response-item.tsx
+++ b/codex-cli/src/components/chat/terminal-chat-response-item.tsx
@@ -8,6 +8,7 @@ import type {
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
+import type { FileOpenerScheme } from "src/utils/config";
import { useTerminalSize } from "../../hooks/use-terminal-size";
import { collapseXmlBlocks } from "../../utils/file-tag-utils";
@@ -16,16 +17,20 @@ import chalk, { type ForegroundColorName } from "chalk";
import { Box, Text } from "ink";
import { parse, setOptions } from "marked";
import TerminalRenderer from "marked-terminal";
+import path from "path";
import React, { useEffect, useMemo } from "react";
+import supportsHyperlinks from "supports-hyperlinks";
export default function TerminalChatResponseItem({
item,
fullStdout = false,
setOverlayMode,
+ fileOpener,
}: {
item: ResponseItem;
fullStdout?: boolean;
setOverlayMode?: React.Dispatch>;
+ fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement {
switch (item.type) {
case "message":
@@ -33,6 +38,7 @@ export default function TerminalChatResponseItem({
);
case "function_call":
@@ -50,7 +56,9 @@ export default function TerminalChatResponseItem({
// @ts-expect-error `reasoning` is not in the responses API yet
if (item.type === "reasoning") {
- return ;
+ return (
+
+ );
}
return ;
@@ -78,8 +86,10 @@ export default function TerminalChatResponseItem({
export function TerminalChatResponseReasoning({
message,
+ fileOpener,
}: {
message: ResponseReasoningItem & { duration_ms?: number };
+ fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement | null {
// Only render when there is a reasoning summary
if (!message.summary || message.summary.length === 0) {
@@ -92,7 +102,7 @@ export function TerminalChatResponseReasoning({
return (
{s.headline && {s.headline}}
- {s.text}
+ {s.text}
);
})}
@@ -108,9 +118,11 @@ const colorsByRole: Record = {
function TerminalChatResponseMessage({
message,
setOverlayMode,
+ fileOpener,
}: {
message: ResponseInputMessageItem | ResponseOutputMessage;
setOverlayMode?: React.Dispatch>;
+ fileOpener: FileOpenerScheme | undefined;
}) {
// auto switch to model mode if the system message contains "has been deprecated"
useEffect(() => {
@@ -129,7 +141,7 @@ function TerminalChatResponseMessage({
{message.role === "assistant" ? "codex" : message.role}
-
+
{message.content
.map(
(c) =>
@@ -240,26 +252,87 @@ export function TerminalChatResponseGenericMessage({
export type MarkdownProps = TerminalRendererOptions & {
children: string;
+ fileOpener: FileOpenerScheme | undefined;
+ /** Base path for resolving relative file citation paths. */
+ cwd?: string;
};
export function Markdown({
children,
+ fileOpener,
+ cwd,
...options
}: MarkdownProps): React.ReactElement {
const size = useTerminalSize();
const rendered = React.useMemo(() => {
+ const linkifiedMarkdown = rewriteFileCitations(children, fileOpener, cwd);
+
// Configure marked for this specific render
setOptions({
// @ts-expect-error missing parser, space props
renderer: new TerminalRenderer({ ...options, width: size.columns }),
});
- const parsed = parse(children, { async: false }).trim();
+ const parsed = parse(linkifiedMarkdown, { async: false }).trim();
// Remove the truncation logic
return parsed;
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is an object of primitives
- }, [children, size.columns, size.rows]);
+ }, [
+ children,
+ size.columns,
+ size.rows,
+ fileOpener,
+ supportsHyperlinks.stdout,
+ chalk.level,
+ ]);
return {rendered};
}
+
+/** Regex to match citations for source files (hence the `F:` prefix). */
+const citationRegex = new RegExp(
+ [
+ // Opening marker
+ "【",
+
+ // Capture group 1: file ID or name (anything except '†')
+ "F:([^†]+)",
+
+ // Field separator
+ "†",
+
+ // Capture group 2: start line (digits)
+ "L(\\d+)",
+
+ // Non-capturing group for optional end line
+ "(?:",
+
+ // Capture group 3: end line (digits or '?')
+ "-L(\\d+|\\?)",
+
+ // End of optional group (may not be present)
+ ")?",
+
+ // Closing marker
+ "】",
+ ].join(""),
+ "g", // Global flag
+);
+
+function rewriteFileCitations(
+ markdown: string,
+ fileOpener: FileOpenerScheme | undefined,
+ cwd: string = process.cwd(),
+): string {
+ if (!fileOpener) {
+ // Should we reformat the citations even if we cannot linkify them?
+ return markdown;
+ }
+
+ return markdown.replace(citationRegex, (_match, file, start, _end) => {
+ const absPath = path.resolve(cwd, file);
+ const uri = `${fileOpener}://file${absPath}:${start}`;
+ return `[${file}](${uri})`;
+ });
+}
diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx
index f34ab792..8eefae8c 100644
--- a/codex-cli/src/components/chat/terminal-chat.tsx
+++ b/codex-cli/src/components/chat/terminal-chat.tsx
@@ -480,6 +480,7 @@ export default function TerminalChat({
initialImagePaths,
flexModeEnabled: Boolean(config.flexMode),
}}
+ fileOpener={config.fileOpener}
/>
) : (
diff --git a/codex-cli/src/components/chat/terminal-message-history.tsx b/codex-cli/src/components/chat/terminal-message-history.tsx
index 8171f629..5ecf7fe0 100644
--- a/codex-cli/src/components/chat/terminal-message-history.tsx
+++ b/codex-cli/src/components/chat/terminal-message-history.tsx
@@ -2,6 +2,7 @@ import type { OverlayModeType } from "./terminal-chat.js";
import type { TerminalHeaderProps } from "./terminal-header.js";
import type { GroupedResponseItem } from "./use-message-grouping.js";
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
+import type { FileOpenerScheme } from "src/utils/config.js";
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
import TerminalHeader from "./terminal-header.js";
@@ -23,6 +24,7 @@ type TerminalMessageHistoryProps = {
headerProps: TerminalHeaderProps;
fullStdout: boolean;
setOverlayMode: React.Dispatch>;
+ fileOpener: FileOpenerScheme | undefined;
};
const TerminalMessageHistory: React.FC = ({
@@ -33,6 +35,7 @@ const TerminalMessageHistory: React.FC = ({
thinkingSeconds: _thinkingSeconds,
fullStdout,
setOverlayMode,
+ fileOpener,
}) => {
// Flatten batch entries to response items.
const messages = useMemo(() => batch.map(({ item }) => item!), [batch]);
@@ -69,6 +72,7 @@ const TerminalMessageHistory: React.FC = ({
item={message}
fullStdout={fullStdout}
setOverlayMode={setOverlayMode}
+ fileOpener={fileOpener}
/>
);
diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts
index 9e9de7e9..d151c05f 100644
--- a/codex-cli/src/utils/config.ts
+++ b/codex-cli/src/utils/config.ts
@@ -135,6 +135,8 @@ export function getApiKey(provider: string = "openai"): string | undefined {
return undefined;
}
+export type FileOpenerScheme = "vscode" | "cursor" | "windsurf";
+
// Represents config as persisted in config.json.
export type StoredConfig = {
model?: string;
@@ -162,6 +164,12 @@ export type StoredConfig = {
/** User-defined safe commands */
safeCommands?: Array;
reasoningEffort?: ReasoningEffort;
+
+ /**
+ * URI-based file opener. This is used when linking code references in
+ * terminal output.
+ */
+ fileOpener?: FileOpenerScheme;
};
// Minimal config written on first run. An *empty* model string ensures that
@@ -206,6 +214,7 @@ export type AppConfig = {
maxLines: number;
};
};
+ fileOpener?: FileOpenerScheme;
};
// Formatting (quiet mode-only).
@@ -429,6 +438,7 @@ export const loadConfig = (
},
disableResponseStorage: storedConfig.disableResponseStorage === true,
reasoningEffort: storedConfig.reasoningEffort,
+ fileOpener: storedConfig.fileOpener,
};
// -----------------------------------------------------------------------
diff --git a/codex-cli/tests/markdown.test.tsx b/codex-cli/tests/markdown.test.tsx
index 87d75a9c..dd18b66d 100644
--- a/codex-cli/tests/markdown.test.tsx
+++ b/codex-cli/tests/markdown.test.tsx
@@ -1,16 +1,70 @@
+import type { ColorSupportLevel } from "chalk";
+
import { renderTui } from "./ui-test-helpers.js";
import { Markdown } from "../src/components/chat/terminal-chat-response-item.js";
import React from "react";
-import { it, expect } from "vitest";
+import { describe, afterEach, beforeEach, it, expect, vi } from "vitest";
+import chalk from "chalk";
/** Simple sanity check that the Markdown component renders bold/italic text.
* We strip ANSI codes, so the output should contain the raw words. */
it("renders basic markdown", () => {
const { lastFrameStripped } = renderTui(
- **bold** _italic_,
+ **bold** _italic_,
);
const frame = lastFrameStripped();
expect(frame).toContain("bold");
expect(frame).toContain("italic");
});
+
+describe("ensure produces content with correct ANSI escape codes", () => {
+ let chalkOriginalLevel: ColorSupportLevel = 0;
+
+ beforeEach(() => {
+ chalkOriginalLevel = chalk.level;
+ chalk.level = 3;
+
+ vi.mock("supports-hyperlinks", () => ({
+ default: {},
+ supportsHyperlink: () => true,
+ stdout: true,
+ stderr: true,
+ }));
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ chalk.level = chalkOriginalLevel;
+ });
+
+ it("renders basic markdown with ansi", () => {
+ const { lastFrame } = renderTui(
+ **bold** _italic_,
+ );
+
+ const frame = lastFrame();
+ const BOLD = "\x1B[1m";
+ const BOLD_OFF = "\x1B[22m";
+ const ITALIC = "\x1B[3m";
+ const ITALIC_OFF = "\x1B[23m";
+ expect(frame).toBe(`${BOLD}bold${BOLD_OFF} ${ITALIC}italic${ITALIC_OFF}`);
+ });
+
+ it("citations should get converted to hyperlinks when stdout supports them", () => {
+ const { lastFrame } = renderTui(
+
+ File with TODO: 【F:src/approvals.ts†L40】
+ ,
+ );
+
+ const BLUE = "\x1B[34m";
+ const LINK_ON = "\x1B[4m";
+ const LINK_OFF = "\x1B[24m";
+ const COLOR_OFF = "\x1B[39m";
+
+ const expected = `File with TODO: ${BLUE}src/approvals.ts (${LINK_ON}vscode://file/foo/bar/src/approvals.ts:40${LINK_OFF})${COLOR_OFF}`;
+ const outputWithAnsi = lastFrame();
+ expect(outputWithAnsi).toBe(expected);
+ });
+});
diff --git a/codex-cli/tests/terminal-chat-response-item.test.tsx b/codex-cli/tests/terminal-chat-response-item.test.tsx
index 14b4efa6..758532a3 100644
--- a/codex-cli/tests/terminal-chat-response-item.test.tsx
+++ b/codex-cli/tests/terminal-chat-response-item.test.tsx
@@ -38,7 +38,10 @@ function assistantMessage(text: string) {
describe("TerminalChatResponseItem", () => {
it("renders a user message", () => {
const { lastFrameStripped } = renderTui(
- ,
+ ,
);
const frame = lastFrameStripped();
@@ -48,7 +51,10 @@ describe("TerminalChatResponseItem", () => {
it("renders an assistant message", () => {
const { lastFrameStripped } = renderTui(
- ,
+ ,
);
const frame = lastFrameStripped();