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