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:
@@ -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})`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user