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