fix: add support for fileOpener in config.json (#911)

This PR introduces the following type:

```typescript
export type FileOpenerScheme = "vscode" | "cursor" | "windsurf";
```

and uses it as the new type for a `fileOpener` option in `config.json`.
If set, this will be used to linkify file annotations in the output
using the URI-based file opener supported in VS Code-based IDEs.

Currently, this does not pass:

Updated `codex-cli/tests/markdown.test.tsx` to verify the new behavior.
Note it required mocking `supports-hyperlinks` and temporarily modifying
`chalk.level` to yield the desired output.
This commit is contained in:
Michael Bolin
2025-05-13 09:45:46 -07:00
committed by GitHub
parent 05bb5d7d46
commit 557f608f25
9 changed files with 174 additions and 12 deletions

View File

@@ -50,6 +50,7 @@ export default function App({
<TerminalChatPastRollout <TerminalChatPastRollout
session={rollout.session} session={rollout.session}
items={rollout.items} items={rollout.items}
fileOpener={config.fileOpener}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
import type { TerminalHeaderProps } from "./terminal-header.js"; import type { TerminalHeaderProps } from "./terminal-header.js";
import type { GroupedResponseItem } from "./use-message-grouping.js"; import type { GroupedResponseItem } from "./use-message-grouping.js";
import type { ResponseItem } from "openai/resources/responses/responses.mjs"; 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 TerminalChatResponseItem from "./terminal-chat-response-item.js";
import TerminalHeader from "./terminal-header.js"; import TerminalHeader from "./terminal-header.js";
@@ -19,11 +20,13 @@ type MessageHistoryProps = {
confirmationPrompt: React.ReactNode; confirmationPrompt: React.ReactNode;
loading: boolean; loading: boolean;
headerProps: TerminalHeaderProps; headerProps: TerminalHeaderProps;
fileOpener: FileOpenerScheme | undefined;
}; };
const MessageHistory: React.FC<MessageHistoryProps> = ({ const MessageHistory: React.FC<MessageHistoryProps> = ({
batch, batch,
headerProps, headerProps,
fileOpener,
}) => { }) => {
const messages = batch.map(({ item }) => item!); const messages = batch.map(({ item }) => item!);
@@ -68,7 +71,10 @@ const MessageHistory: React.FC<MessageHistoryProps> = ({
message.type === "message" && message.role === "user" ? 0 : 1 message.type === "message" && message.role === "user" ? 0 : 1
} }
> >
<TerminalChatResponseItem item={message} /> <TerminalChatResponseItem
item={message}
fileOpener={fileOpener}
/>
</Box> </Box>
); );
}} }}

View File

@@ -1,5 +1,6 @@
import type { TerminalChatSession } from "../../utils/session.js"; import type { TerminalChatSession } from "../../utils/session.js";
import type { ResponseItem } from "openai/resources/responses/responses"; import type { ResponseItem } from "openai/resources/responses/responses";
import type { FileOpenerScheme } from "src/utils/config.js";
import TerminalChatResponseItem from "./terminal-chat-response-item"; import TerminalChatResponseItem from "./terminal-chat-response-item";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
@@ -8,9 +9,11 @@ import React from "react";
export default function TerminalChatPastRollout({ export default function TerminalChatPastRollout({
session, session,
items, items,
fileOpener,
}: { }: {
session: TerminalChatSession; session: TerminalChatSession;
items: Array<ResponseItem>; items: Array<ResponseItem>;
fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement { }): React.ReactElement {
const { version, id: sessionId, model } = session; const { version, id: sessionId, model } = session;
return ( return (
@@ -51,9 +54,13 @@ export default function TerminalChatPastRollout({
{React.useMemo( {React.useMemo(
() => () =>
items.map((item, key) => ( items.map((item, key) => (
<TerminalChatResponseItem key={key} item={item} /> <TerminalChatResponseItem
key={key}
item={item}
fileOpener={fileOpener}
/>
)), )),
[items], [items, fileOpener],
)} )}
</Box> </Box>
</Box> </Box>

View File

@@ -8,6 +8,7 @@ import type {
ResponseOutputMessage, ResponseOutputMessage,
ResponseReasoningItem, ResponseReasoningItem,
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import type { FileOpenerScheme } from "src/utils/config";
import { useTerminalSize } from "../../hooks/use-terminal-size"; import { useTerminalSize } from "../../hooks/use-terminal-size";
import { collapseXmlBlocks } from "../../utils/file-tag-utils"; import { collapseXmlBlocks } from "../../utils/file-tag-utils";
@@ -16,16 +17,20 @@ import chalk, { type ForegroundColorName } from "chalk";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import { parse, setOptions } from "marked"; import { parse, setOptions } from "marked";
import TerminalRenderer from "marked-terminal"; import TerminalRenderer from "marked-terminal";
import path from "path";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import supportsHyperlinks from "supports-hyperlinks";
export default function TerminalChatResponseItem({ export default function TerminalChatResponseItem({
item, item,
fullStdout = false, fullStdout = false,
setOverlayMode, setOverlayMode,
fileOpener,
}: { }: {
item: ResponseItem; item: ResponseItem;
fullStdout?: boolean; fullStdout?: boolean;
setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>; setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement { }): React.ReactElement {
switch (item.type) { switch (item.type) {
case "message": case "message":
@@ -33,6 +38,7 @@ export default function TerminalChatResponseItem({
<TerminalChatResponseMessage <TerminalChatResponseMessage
setOverlayMode={setOverlayMode} setOverlayMode={setOverlayMode}
message={item} message={item}
fileOpener={fileOpener}
/> />
); );
case "function_call": case "function_call":
@@ -50,7 +56,9 @@ export default function TerminalChatResponseItem({
// @ts-expect-error `reasoning` is not in the responses API yet // @ts-expect-error `reasoning` is not in the responses API yet
if (item.type === "reasoning") { if (item.type === "reasoning") {
return <TerminalChatResponseReasoning message={item} />; return (
<TerminalChatResponseReasoning message={item} fileOpener={fileOpener} />
);
} }
return <TerminalChatResponseGenericMessage message={item} />; return <TerminalChatResponseGenericMessage message={item} />;
@@ -78,8 +86,10 @@ export default function TerminalChatResponseItem({
export function TerminalChatResponseReasoning({ export function TerminalChatResponseReasoning({
message, message,
fileOpener,
}: { }: {
message: ResponseReasoningItem & { duration_ms?: number }; message: ResponseReasoningItem & { duration_ms?: number };
fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement | null { }): React.ReactElement | null {
// Only render when there is a reasoning summary // Only render when there is a reasoning summary
if (!message.summary || message.summary.length === 0) { if (!message.summary || message.summary.length === 0) {
@@ -92,7 +102,7 @@ export function TerminalChatResponseReasoning({
return ( return (
<Box key={key} flexDirection="column"> <Box key={key} flexDirection="column">
{s.headline && <Text bold>{s.headline}</Text>} {s.headline && <Text bold>{s.headline}</Text>}
<Markdown>{s.text}</Markdown> <Markdown fileOpener={fileOpener}>{s.text}</Markdown>
</Box> </Box>
); );
})} })}
@@ -108,9 +118,11 @@ const colorsByRole: Record<string, ForegroundColorName> = {
function TerminalChatResponseMessage({ function TerminalChatResponseMessage({
message, message,
setOverlayMode, setOverlayMode,
fileOpener,
}: { }: {
message: ResponseInputMessageItem | ResponseOutputMessage; message: ResponseInputMessageItem | ResponseOutputMessage;
setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>; setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
}) { }) {
// auto switch to model mode if the system message contains "has been deprecated" // auto switch to model mode if the system message contains "has been deprecated"
useEffect(() => { useEffect(() => {
@@ -129,7 +141,7 @@ function TerminalChatResponseMessage({
<Text bold color={colorsByRole[message.role] || "gray"}> <Text bold color={colorsByRole[message.role] || "gray"}>
{message.role === "assistant" ? "codex" : message.role} {message.role === "assistant" ? "codex" : message.role}
</Text> </Text>
<Markdown> <Markdown fileOpener={fileOpener}>
{message.content {message.content
.map( .map(
(c) => (c) =>
@@ -240,26 +252,87 @@ export function TerminalChatResponseGenericMessage({
export type MarkdownProps = TerminalRendererOptions & { export type MarkdownProps = TerminalRendererOptions & {
children: string; children: string;
fileOpener: FileOpenerScheme | undefined;
/** Base path for resolving relative file citation paths. */
cwd?: string;
}; };
export function Markdown({ export function Markdown({
children, children,
fileOpener,
cwd,
...options ...options
}: MarkdownProps): React.ReactElement { }: MarkdownProps): React.ReactElement {
const size = useTerminalSize(); const size = useTerminalSize();
const rendered = React.useMemo(() => { const rendered = React.useMemo(() => {
const linkifiedMarkdown = rewriteFileCitations(children, fileOpener, cwd);
// Configure marked for this specific render // Configure marked for this specific render
setOptions({ setOptions({
// @ts-expect-error missing parser, space props // @ts-expect-error missing parser, space props
renderer: new TerminalRenderer({ ...options, width: size.columns }), 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 // Remove the truncation logic
return parsed; return parsed;
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is an object of primitives // 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 <Text>{rendered}</Text>; return <Text>{rendered}</Text>;
} }
/** 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})`;
});
}

View File

@@ -480,6 +480,7 @@ export default function TerminalChat({
initialImagePaths, initialImagePaths,
flexModeEnabled: Boolean(config.flexMode), flexModeEnabled: Boolean(config.flexMode),
}} }}
fileOpener={config.fileOpener}
/> />
) : ( ) : (
<Box> <Box>

View File

@@ -2,6 +2,7 @@ import type { OverlayModeType } from "./terminal-chat.js";
import type { TerminalHeaderProps } from "./terminal-header.js"; import type { TerminalHeaderProps } from "./terminal-header.js";
import type { GroupedResponseItem } from "./use-message-grouping.js"; import type { GroupedResponseItem } from "./use-message-grouping.js";
import type { ResponseItem } from "openai/resources/responses/responses.mjs"; 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 TerminalChatResponseItem from "./terminal-chat-response-item.js";
import TerminalHeader from "./terminal-header.js"; import TerminalHeader from "./terminal-header.js";
@@ -23,6 +24,7 @@ type TerminalMessageHistoryProps = {
headerProps: TerminalHeaderProps; headerProps: TerminalHeaderProps;
fullStdout: boolean; fullStdout: boolean;
setOverlayMode: React.Dispatch<React.SetStateAction<OverlayModeType>>; setOverlayMode: React.Dispatch<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
}; };
const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
@@ -33,6 +35,7 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
thinkingSeconds: _thinkingSeconds, thinkingSeconds: _thinkingSeconds,
fullStdout, fullStdout,
setOverlayMode, setOverlayMode,
fileOpener,
}) => { }) => {
// Flatten batch entries to response items. // Flatten batch entries to response items.
const messages = useMemo(() => batch.map(({ item }) => item!), [batch]); const messages = useMemo(() => batch.map(({ item }) => item!), [batch]);
@@ -69,6 +72,7 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
item={message} item={message}
fullStdout={fullStdout} fullStdout={fullStdout}
setOverlayMode={setOverlayMode} setOverlayMode={setOverlayMode}
fileOpener={fileOpener}
/> />
</Box> </Box>
); );

View File

@@ -135,6 +135,8 @@ export function getApiKey(provider: string = "openai"): string | undefined {
return undefined; return undefined;
} }
export type FileOpenerScheme = "vscode" | "cursor" | "windsurf";
// Represents config as persisted in config.json. // Represents config as persisted in config.json.
export type StoredConfig = { export type StoredConfig = {
model?: string; model?: string;
@@ -162,6 +164,12 @@ export type StoredConfig = {
/** User-defined safe commands */ /** User-defined safe commands */
safeCommands?: Array<string>; safeCommands?: Array<string>;
reasoningEffort?: ReasoningEffort; 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 // Minimal config written on first run. An *empty* model string ensures that
@@ -206,6 +214,7 @@ export type AppConfig = {
maxLines: number; maxLines: number;
}; };
}; };
fileOpener?: FileOpenerScheme;
}; };
// Formatting (quiet mode-only). // Formatting (quiet mode-only).
@@ -429,6 +438,7 @@ export const loadConfig = (
}, },
disableResponseStorage: storedConfig.disableResponseStorage === true, disableResponseStorage: storedConfig.disableResponseStorage === true,
reasoningEffort: storedConfig.reasoningEffort, reasoningEffort: storedConfig.reasoningEffort,
fileOpener: storedConfig.fileOpener,
}; };
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -1,16 +1,70 @@
import type { ColorSupportLevel } from "chalk";
import { renderTui } from "./ui-test-helpers.js"; import { renderTui } from "./ui-test-helpers.js";
import { Markdown } from "../src/components/chat/terminal-chat-response-item.js"; import { Markdown } from "../src/components/chat/terminal-chat-response-item.js";
import React from "react"; 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. /** Simple sanity check that the Markdown component renders bold/italic text.
* We strip ANSI codes, so the output should contain the raw words. */ * We strip ANSI codes, so the output should contain the raw words. */
it("renders basic markdown", () => { it("renders basic markdown", () => {
const { lastFrameStripped } = renderTui( const { lastFrameStripped } = renderTui(
<Markdown>**bold** _italic_</Markdown>, <Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
); );
const frame = lastFrameStripped(); const frame = lastFrameStripped();
expect(frame).toContain("bold"); expect(frame).toContain("bold");
expect(frame).toContain("italic"); expect(frame).toContain("italic");
}); });
describe("ensure <Markdown> 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(
<Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
);
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(
<Markdown fileOpener={"vscode"} cwd="/foo/bar">
File with TODO: F:src/approvals.tsL40
</Markdown>,
);
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);
});
});

View File

@@ -38,7 +38,10 @@ function assistantMessage(text: string) {
describe("TerminalChatResponseItem", () => { describe("TerminalChatResponseItem", () => {
it("renders a user message", () => { it("renders a user message", () => {
const { lastFrameStripped } = renderTui( const { lastFrameStripped } = renderTui(
<TerminalChatResponseItem item={userMessage("Hello world")} />, <TerminalChatResponseItem
item={userMessage("Hello world")}
fileOpener={undefined}
/>,
); );
const frame = lastFrameStripped(); const frame = lastFrameStripped();
@@ -48,7 +51,10 @@ describe("TerminalChatResponseItem", () => {
it("renders an assistant message", () => { it("renders an assistant message", () => {
const { lastFrameStripped } = renderTui( const { lastFrameStripped } = renderTui(
<TerminalChatResponseItem item={assistantMessage("Sure thing")} />, <TerminalChatResponseItem
item={assistantMessage("Sure thing")}
fileOpener={undefined}
/>,
); );
const frame = lastFrameStripped(); const frame = lastFrameStripped();