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:
@@ -50,6 +50,7 @@ export default function App({
|
||||
<TerminalChatPastRollout
|
||||
session={rollout.session}
|
||||
items={rollout.items}
|
||||
fileOpener={config.fileOpener}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,6 +480,7 @@ export default function TerminalChat({
|
||||
initialImagePaths,
|
||||
flexModeEnabled: Boolean(config.flexMode),
|
||||
}}
|
||||
fileOpener={config.fileOpener}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user