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
session={rollout.session}
items={rollout.items}
fileOpener={config.fileOpener}
/>
);
}

View File

@@ -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<MessageHistoryProps> = ({
batch,
headerProps,
fileOpener,
}) => {
const messages = batch.map(({ item }) => item!);
@@ -68,7 +71,10 @@ const MessageHistory: React.FC<MessageHistoryProps> = ({
message.type === "message" && message.role === "user" ? 0 : 1
}
>
<TerminalChatResponseItem item={message} />
<TerminalChatResponseItem
item={message}
fileOpener={fileOpener}
/>
</Box>
);
}}

View File

@@ -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<ResponseItem>;
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) => (
<TerminalChatResponseItem key={key} item={item} />
<TerminalChatResponseItem
key={key}
item={item}
fileOpener={fileOpener}
/>
)),
[items],
[items, fileOpener],
)}
</Box>
</Box>

View File

@@ -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<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
}): React.ReactElement {
switch (item.type) {
case "message":
@@ -33,6 +38,7 @@ export default function TerminalChatResponseItem({
<TerminalChatResponseMessage
setOverlayMode={setOverlayMode}
message={item}
fileOpener={fileOpener}
/>
);
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 <TerminalChatResponseReasoning message={item} />;
return (
<TerminalChatResponseReasoning message={item} fileOpener={fileOpener} />
);
}
return <TerminalChatResponseGenericMessage message={item} />;
@@ -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 (
<Box key={key} flexDirection="column">
{s.headline && <Text bold>{s.headline}</Text>}
<Markdown>{s.text}</Markdown>
<Markdown fileOpener={fileOpener}>{s.text}</Markdown>
</Box>
);
})}
@@ -108,9 +118,11 @@ const colorsByRole: Record<string, ForegroundColorName> = {
function TerminalChatResponseMessage({
message,
setOverlayMode,
fileOpener,
}: {
message: ResponseInputMessageItem | ResponseOutputMessage;
setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
}) {
// auto switch to model mode if the system message contains "has been deprecated"
useEffect(() => {
@@ -129,7 +141,7 @@ function TerminalChatResponseMessage({
<Text bold color={colorsByRole[message.role] || "gray"}>
{message.role === "assistant" ? "codex" : message.role}
</Text>
<Markdown>
<Markdown fileOpener={fileOpener}>
{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 <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,
flexModeEnabled: Boolean(config.flexMode),
}}
fileOpener={config.fileOpener}
/>
) : (
<Box>

View File

@@ -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<React.SetStateAction<OverlayModeType>>;
fileOpener: FileOpenerScheme | undefined;
};
const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
@@ -33,6 +35,7 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
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<TerminalMessageHistoryProps> = ({
item={message}
fullStdout={fullStdout}
setOverlayMode={setOverlayMode}
fileOpener={fileOpener}
/>
</Box>
);

View File

@@ -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<string>;
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,
};
// -----------------------------------------------------------------------