103
codex-cli/src/app.tsx
Normal file
103
codex-cli/src/app.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { AppConfig } from "./utils/config";
|
||||
import type { ApprovalPolicy } from "@lib/approvals";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import TerminalChat from "./components/chat/terminal-chat";
|
||||
import TerminalChatPastRollout from "./components/chat/terminal-chat-past-rollout";
|
||||
import { checkInGit } from "./utils/check-in-git";
|
||||
import { CLI_VERSION, type TerminalChatSession } from "./utils/session.js";
|
||||
import { onExit } from "./utils/terminal";
|
||||
import { ConfirmInput } from "@inkjs/ui";
|
||||
import { Box, Text, useApp, useStdin } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
export type AppRollout = {
|
||||
session: TerminalChatSession;
|
||||
items: Array<ResponseItem>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
prompt?: string;
|
||||
config: AppConfig;
|
||||
imagePaths?: Array<string>;
|
||||
rollout?: AppRollout;
|
||||
approvalPolicy: ApprovalPolicy;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
export default function App({
|
||||
prompt,
|
||||
config,
|
||||
rollout,
|
||||
imagePaths,
|
||||
approvalPolicy,
|
||||
fullStdout,
|
||||
}: Props): JSX.Element {
|
||||
const app = useApp();
|
||||
const [accepted, setAccepted] = useState(() => false);
|
||||
const [cwd, inGitRepo] = useMemo(
|
||||
() => [process.cwd(), checkInGit(process.cwd())],
|
||||
[],
|
||||
);
|
||||
const { internal_eventEmitter } = useStdin();
|
||||
internal_eventEmitter.setMaxListeners(20);
|
||||
|
||||
if (rollout) {
|
||||
return (
|
||||
<TerminalChatPastRollout
|
||||
session={rollout.session}
|
||||
items={rollout.items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inGitRepo && !accepted) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round" paddingX={1} width={64}>
|
||||
<Text>
|
||||
● OpenAI <Text bold>Codex</Text>{" "}
|
||||
<Text dimColor>
|
||||
(research preview) <Text color="blueBright">v{CLI_VERSION}</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="redBright"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
>
|
||||
<Text>
|
||||
<Text color="yellow">Warning!</Text> It can be dangerous to run a
|
||||
coding agent outside of a git repo in case there are changes that
|
||||
you want to revert. Do you want to continue?
|
||||
</Text>
|
||||
<Text>{cwd}</Text>
|
||||
<ConfirmInput
|
||||
defaultChoice="cancel"
|
||||
onCancel={() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
// eslint-disable-next-line
|
||||
console.error(
|
||||
"Quitting! Run again to accept or from inside a git repo",
|
||||
);
|
||||
}}
|
||||
onConfirm={() => setAccepted(true)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalChat
|
||||
config={config}
|
||||
prompt={prompt}
|
||||
imagePaths={imagePaths}
|
||||
approvalPolicy={approvalPolicy}
|
||||
fullStdout={fullStdout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
393
codex-cli/src/cli.tsx
Normal file
393
codex-cli/src/cli.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env -S NODE_OPTIONS=--no-deprecation node
|
||||
|
||||
import type { AppRollout } from "./app";
|
||||
import type { CommandConfirmation } from "./utils/agent/agent-loop";
|
||||
import type { AppConfig } from "./utils/config";
|
||||
import type { ApprovalPolicy } from "@lib/approvals";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import App from "./app";
|
||||
import { runSinglePass } from "./cli_singlepass";
|
||||
import { AgentLoop } from "./utils/agent/agent-loop";
|
||||
import { initLogger } from "./utils/agent/log";
|
||||
import { ReviewDecision } from "./utils/agent/review";
|
||||
import { AutoApprovalMode } from "./utils/auto-approval-mode";
|
||||
import { loadConfig, PRETTY_PRINT } from "./utils/config";
|
||||
import { createInputItem } from "./utils/input-utils";
|
||||
import {
|
||||
isModelSupportedForResponses,
|
||||
preloadModels,
|
||||
} from "./utils/model-utils.js";
|
||||
import { parseToolCall } from "./utils/parsers";
|
||||
import { onExit, setInkRenderer } from "./utils/terminal";
|
||||
import chalk from "chalk";
|
||||
import fs from "fs";
|
||||
import { render } from "ink";
|
||||
import meow from "meow";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
|
||||
// Call this early so `tail -F "$TMPDIR/oai-codex/codex-cli-latest.log"` works
|
||||
// immediately. This must be run with DEBUG=1 for logging to work.
|
||||
initLogger();
|
||||
|
||||
// TODO: migrate to new versions of quiet mode
|
||||
//
|
||||
// -q, --quiet Non-interactive quiet mode that only prints final message
|
||||
// -j, --json Non-interactive JSON output mode that prints JSON messages
|
||||
|
||||
const cli = meow(
|
||||
`
|
||||
Usage
|
||||
$ codex [options] <prompt>
|
||||
|
||||
Options
|
||||
-h, --help Show usage and exit
|
||||
-m, --model <model> Model to use for completions (default: o3)
|
||||
-i, --image <path> Path(s) to image files to include as input
|
||||
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
|
||||
-q, --quiet Non-interactive mode that only prints the assistant's final output
|
||||
-a, --approval-mode <mode> Override the approval policy: 'suggest', 'auto-edit', or 'full-auto'
|
||||
|
||||
--auto-edit Automatically approve file edits; still prompt for commands
|
||||
--full-auto Automatically approve edits and commands when executed in the sandbox
|
||||
|
||||
--no-project-doc Do not automatically include the repository's 'codex.md'
|
||||
--project-doc <file> Include an additional markdown file at <file> as context
|
||||
--full-stdout Do not truncate stdout/stderr from command outputs
|
||||
|
||||
Dangerous options
|
||||
--dangerously-auto-approve-everything
|
||||
Skip all confirmation prompts and execute commands without
|
||||
sandboxing. Intended solely for ephemeral local testing.
|
||||
|
||||
Experimental options
|
||||
-f, --full-context Launch in "full-context" mode which loads the entire repository
|
||||
into context and applies a batch of edits in one go. Incompatible
|
||||
with all other flags, except for --model.
|
||||
|
||||
Examples
|
||||
$ codex "Write and run a python program that prints ASCII art"
|
||||
$ codex -q "fix build issues"
|
||||
`,
|
||||
{
|
||||
importMeta: import.meta,
|
||||
autoHelp: true,
|
||||
flags: {
|
||||
// misc
|
||||
help: { type: "boolean", aliases: ["h"] },
|
||||
view: { type: "string" },
|
||||
model: { type: "string", aliases: ["m"] },
|
||||
image: { type: "string", isMultiple: true, aliases: ["i"] },
|
||||
quiet: {
|
||||
type: "boolean",
|
||||
aliases: ["q"],
|
||||
description: "Non-interactive quiet mode",
|
||||
},
|
||||
dangerouslyAutoApproveEverything: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Automatically approve all commands without prompting. This is EXTREMELY DANGEROUS and should only be used in trusted environments.",
|
||||
},
|
||||
autoEdit: {
|
||||
type: "boolean",
|
||||
description: "Automatically approve edits; prompt for commands.",
|
||||
},
|
||||
fullAuto: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Automatically run commands in a sandbox; only prompt for failures.",
|
||||
},
|
||||
approvalMode: {
|
||||
type: "string",
|
||||
aliases: ["a"],
|
||||
description:
|
||||
"Determine the approval mode for Codex (default: suggest) Values: suggest, auto-edit, full-auto",
|
||||
},
|
||||
noProjectDoc: {
|
||||
type: "boolean",
|
||||
description: "Disable automatic inclusion of project‑level codex.md",
|
||||
},
|
||||
projectDoc: {
|
||||
type: "string",
|
||||
description: "Path to a markdown file to include as project doc",
|
||||
},
|
||||
fullStdout: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Disable truncation of command stdout/stderr messages (show everything)",
|
||||
aliases: ["no-truncate"],
|
||||
},
|
||||
|
||||
// Experimental mode where whole directory is loaded in context and model is requested
|
||||
// to make code edits in a single pass.
|
||||
fullContext: {
|
||||
type: "boolean",
|
||||
aliases: ["f"],
|
||||
description: `Run in full-context editing approach. The model is given the whole code
|
||||
directory as context and performs changes in one go without acting.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (cli.flags.help) {
|
||||
cli.showHelp();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const apiKey = process.env["OPENAI_API_KEY"];
|
||||
|
||||
if (!apiKey) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`\n${chalk.red("Missing OpenAI API key.")}\n\n` +
|
||||
`Set the environment variable ${chalk.bold("OPENAI_API_KEY")} ` +
|
||||
`and re-run this command.\n` +
|
||||
`You can create a key here: ${chalk.bold(
|
||||
chalk.underline("https://platform.openai.com/account/api-keys"),
|
||||
)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullContextMode = Boolean(cli.flags.fullContext);
|
||||
let config = loadConfig(undefined, undefined, {
|
||||
cwd: process.cwd(),
|
||||
disableProjectDoc: Boolean(cli.flags.noProjectDoc),
|
||||
projectDocPath: cli.flags.projectDoc as string | undefined,
|
||||
isFullContext: fullContextMode,
|
||||
});
|
||||
|
||||
const prompt = cli.input[0];
|
||||
const model = cli.flags.model;
|
||||
const imagePaths = cli.flags.image as Array<string> | undefined;
|
||||
|
||||
config = {
|
||||
apiKey,
|
||||
...config,
|
||||
model: model ?? config.model,
|
||||
};
|
||||
|
||||
if (!(await isModelSupportedForResponses(config.model))) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`The model "${config.model}" does not appear in the list of models ` +
|
||||
`available to your account. Double‑check the spelling (use\n` +
|
||||
` openai models list\n` +
|
||||
`to see the full list) or choose another model with the --model flag.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let rollout: AppRollout | undefined;
|
||||
|
||||
if (cli.flags.view) {
|
||||
const viewPath = cli.flags.view;
|
||||
const absolutePath = path.isAbsolute(viewPath)
|
||||
? viewPath
|
||||
: path.join(process.cwd(), viewPath);
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, "utf-8");
|
||||
rollout = JSON.parse(content) as AppRollout;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error reading rollout file:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// If we are running in --fullcontext mode, do that and exit.
|
||||
if (fullContextMode) {
|
||||
await runSinglePass({
|
||||
originalPrompt: prompt,
|
||||
config,
|
||||
rootPath: process.cwd(),
|
||||
});
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// If we are running in --quiet mode, do that and exit.
|
||||
const quietMode = Boolean(cli.flags.quiet);
|
||||
const autoApproveEverything = Boolean(
|
||||
cli.flags.dangerouslyAutoApproveEverything,
|
||||
);
|
||||
const fullStdout = Boolean(cli.flags.fullStdout);
|
||||
|
||||
if (quietMode) {
|
||||
process.env["CODEX_QUIET_MODE"] = "1";
|
||||
if (!prompt || prompt.trim() === "") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'Quiet mode requires a prompt string, e.g.,: codex -q "Fix bug #123 in the foobar project"',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await runQuietMode({
|
||||
prompt: prompt as string,
|
||||
imagePaths: imagePaths || [],
|
||||
approvalPolicy: autoApproveEverything
|
||||
? AutoApprovalMode.FULL_AUTO
|
||||
: AutoApprovalMode.SUGGEST,
|
||||
config,
|
||||
});
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Default to the "suggest" policy.
|
||||
// Determine the approval policy to use in interactive mode.
|
||||
//
|
||||
// Priority (highest → lowest):
|
||||
// 1. --fullAuto – run everything automatically in a sandbox.
|
||||
// 2. --dangerouslyAutoApproveEverything – run everything **without** a sandbox
|
||||
// or prompts. This is intended for completely trusted environments. Since
|
||||
// it is more dangerous than --fullAuto we deliberately give it lower
|
||||
// priority so a user specifying both flags still gets the safer behaviour.
|
||||
// 3. --autoEdit – automatically approve edits, but prompt for commands.
|
||||
// 4. Default – suggest mode (prompt for everything).
|
||||
|
||||
const approvalPolicy: ApprovalPolicy =
|
||||
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
|
||||
? AutoApprovalMode.FULL_AUTO
|
||||
: cli.flags.autoEdit
|
||||
? AutoApprovalMode.AUTO_EDIT
|
||||
: AutoApprovalMode.SUGGEST;
|
||||
|
||||
preloadModels();
|
||||
|
||||
const instance = render(
|
||||
<App
|
||||
prompt={prompt}
|
||||
config={config}
|
||||
rollout={rollout}
|
||||
imagePaths={imagePaths}
|
||||
approvalPolicy={approvalPolicy}
|
||||
fullStdout={fullStdout}
|
||||
/>,
|
||||
{
|
||||
patchConsole: process.env["DEBUG"] ? false : true,
|
||||
},
|
||||
);
|
||||
setInkRenderer(instance);
|
||||
|
||||
function formatResponseItemForQuietMode(item: ResponseItem): string {
|
||||
if (!PRETTY_PRINT) {
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
switch (item.type) {
|
||||
case "message": {
|
||||
const role = item.role === "assistant" ? "assistant" : item.role;
|
||||
const txt = item.content
|
||||
.map((c) => {
|
||||
if (c.type === "output_text" || c.type === "input_text") {
|
||||
return c.text;
|
||||
}
|
||||
if (c.type === "input_image") {
|
||||
return "<Image>";
|
||||
}
|
||||
if (c.type === "input_file") {
|
||||
return c.filename;
|
||||
}
|
||||
if (c.type === "refusal") {
|
||||
return c.refusal;
|
||||
}
|
||||
return "?";
|
||||
})
|
||||
.join(" ");
|
||||
return `${role}: ${txt}`;
|
||||
}
|
||||
case "function_call": {
|
||||
const details = parseToolCall(item);
|
||||
return `$ ${details?.cmdReadableText ?? item.name}`;
|
||||
}
|
||||
case "function_call_output": {
|
||||
// @ts-expect-error metadata unknown on ResponseFunctionToolCallOutputItem
|
||||
const meta = item.metadata as ExecOutputMetadata;
|
||||
const parts: Array<string> = [];
|
||||
if (typeof meta?.exit_code === "number") {
|
||||
parts.push(`code: ${meta.exit_code}`);
|
||||
}
|
||||
if (typeof meta?.duration_seconds === "number") {
|
||||
parts.push(`duration: ${meta.duration_seconds}s`);
|
||||
}
|
||||
const header = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
||||
return `command.stdout${header}\n${item.output}`;
|
||||
}
|
||||
default: {
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runQuietMode({
|
||||
prompt,
|
||||
imagePaths,
|
||||
approvalPolicy,
|
||||
config,
|
||||
}: {
|
||||
prompt: string;
|
||||
imagePaths: Array<string>;
|
||||
approvalPolicy: ApprovalPolicy;
|
||||
config: AppConfig;
|
||||
}): Promise<void> {
|
||||
const agent = new AgentLoop({
|
||||
model: config.model,
|
||||
config: config,
|
||||
instructions: config.instructions,
|
||||
approvalPolicy,
|
||||
onItem: (item: ResponseItem) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(formatResponseItemForQuietMode(item));
|
||||
},
|
||||
onLoading: () => {
|
||||
/* intentionally ignored in quiet mode */
|
||||
},
|
||||
getCommandConfirmation: (
|
||||
_command: Array<string>,
|
||||
): Promise<CommandConfirmation> => {
|
||||
return Promise.resolve({ review: ReviewDecision.NO_CONTINUE });
|
||||
},
|
||||
onLastResponseId: () => {
|
||||
/* intentionally ignored in quiet mode */
|
||||
},
|
||||
});
|
||||
|
||||
const inputItem = await createInputItem(prompt, imagePaths);
|
||||
await agent.run([inputItem]);
|
||||
}
|
||||
|
||||
const exit = () => {
|
||||
onExit();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", exit);
|
||||
process.on("SIGQUIT", exit);
|
||||
process.on("SIGTERM", exit);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback for Ctrl‑C when stdin is in raw‑mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
// Ensure we do not leave the terminal in raw mode if the user presses
|
||||
// Ctrl‑C while some other component has focus and Ink is intercepting
|
||||
// input. Node does *not* emit a SIGINT in raw‑mode, so we listen for the
|
||||
// corresponding byte (0x03) ourselves and trigger a graceful shutdown.
|
||||
const onRawData = (data: Buffer | string): void => {
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\u0003") {
|
||||
exit();
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onRawData);
|
||||
}
|
||||
|
||||
// Ensure terminal clean‑up always runs, even when other code calls
|
||||
// `process.exit()` directly.
|
||||
process.once("exit", onExit);
|
||||
39
codex-cli/src/cli_singlepass.tsx
Normal file
39
codex-cli/src/cli_singlepass.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AppConfig } from "./utils/config";
|
||||
|
||||
import { SinglePassApp } from "./components/singlepass-cli-app";
|
||||
import { render } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export async function runSinglePass({
|
||||
originalPrompt,
|
||||
config,
|
||||
rootPath,
|
||||
}: {
|
||||
originalPrompt?: string;
|
||||
config: AppConfig;
|
||||
rootPath: string;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// In full context mode we want to capture Ctrl+C ourselves so we can use it
|
||||
// to interrupt long‑running requests without force‑quitting the whole
|
||||
// process. Ink exits automatically when it detects Ctrl+C unless
|
||||
// `exitOnCtrlC` is disabled via the render‑options, so make sure to turn it
|
||||
// off here. All other keyboard handling (including optionally exiting when
|
||||
// the user presses Ctrl+C while at the main prompt) is implemented inside
|
||||
// `SinglePassApp`.
|
||||
|
||||
render(
|
||||
<SinglePassApp
|
||||
originalPrompt={originalPrompt}
|
||||
config={config}
|
||||
rootPath={rootPath}
|
||||
onExit={() => resolve()}
|
||||
/>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default {};
|
||||
47
codex-cli/src/components/approval-mode-overlay.tsx
Normal file
47
codex-cli/src/components/approval-mode-overlay.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import TypeaheadOverlay from "./typeahead-overlay.js";
|
||||
import { AutoApprovalMode } from "../utils/auto-approval-mode.js";
|
||||
import { Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
currentMode: string;
|
||||
onSelect: (mode: string) => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Overlay to switch between the different automatic‑approval policies.
|
||||
*
|
||||
* The list of available modes is derived from the AutoApprovalMode enum so we
|
||||
* stay in sync with the core agent behaviour. It re‑uses the generic
|
||||
* TypeaheadOverlay component for the actual UI/UX.
|
||||
*/
|
||||
export default function ApprovalModeOverlay({
|
||||
currentMode,
|
||||
onSelect,
|
||||
onExit,
|
||||
}: Props): JSX.Element {
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
Object.values(AutoApprovalMode).map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<TypeaheadOverlay
|
||||
title="Switch approval mode"
|
||||
description={
|
||||
<Text>
|
||||
Current mode: <Text color="greenBright">{currentMode}</Text>
|
||||
</Text>
|
||||
}
|
||||
initialItems={items}
|
||||
currentValue={currentMode}
|
||||
onSelect={onSelect}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
80
codex-cli/src/components/chat/message-history.tsx
Normal file
80
codex-cli/src/components/chat/message-history.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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 TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
import { Box, Static } from "ink";
|
||||
import React from "react";
|
||||
|
||||
// A batch entry can either be a standalone response item or a grouped set of
|
||||
// items (e.g. auto‑approved tool‑call batches) that should be rendered
|
||||
// together.
|
||||
type BatchEntry = { item?: ResponseItem; group?: GroupedResponseItem };
|
||||
type MessageHistoryProps = {
|
||||
batch: Array<BatchEntry>;
|
||||
groupCounts: Record<string, number>;
|
||||
items: Array<ResponseItem>;
|
||||
userMsgCount: number;
|
||||
confirmationPrompt: React.ReactNode;
|
||||
loading: boolean;
|
||||
headerProps: TerminalHeaderProps;
|
||||
};
|
||||
|
||||
const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
batch,
|
||||
headerProps,
|
||||
}) => {
|
||||
const messages = batch.map(({ item }) => item!);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/*
|
||||
* The Static component receives a mixed array of the literal string
|
||||
* "header" plus the streamed ResponseItem objects. After filtering out
|
||||
* the header entry we can safely treat the remaining values as
|
||||
* ResponseItem, however TypeScript cannot infer the refined type from
|
||||
* the runtime check and therefore reports property‑access errors.
|
||||
*
|
||||
* A short cast after the refinement keeps the implementation tidy while
|
||||
* preserving type‑safety.
|
||||
*/}
|
||||
<Static items={["header", ...messages]}>
|
||||
{(item, index) => {
|
||||
if (item === "header") {
|
||||
return <TerminalHeader key="header" {...headerProps} />;
|
||||
}
|
||||
|
||||
// After the guard above `item` can only be a ResponseItem.
|
||||
const message = item as ResponseItem;
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
borderStyle={
|
||||
message.type === "message" && message.role === "user"
|
||||
? "round"
|
||||
: undefined
|
||||
}
|
||||
borderColor={
|
||||
message.type === "message" && message.role === "user"
|
||||
? "gray"
|
||||
: undefined
|
||||
}
|
||||
marginLeft={
|
||||
message.type === "message" && message.role === "user" ? 0 : 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem item={message} />
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Static>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHistory);
|
||||
409
codex-cli/src/components/chat/multiline-editor.tsx
Normal file
409
codex-cli/src/components/chat/multiline-editor.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size";
|
||||
import TextBuffer from "../../lib/text-buffer.js";
|
||||
import chalk from "chalk";
|
||||
import { Box, Text, useInput, useStdin } from "ink";
|
||||
import { EventEmitter } from "node:events";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Polyfill missing `ref()` / `unref()` methods on the mock `Stdin` stream
|
||||
* provided by `ink-testing-library`.
|
||||
*
|
||||
* The real `process.stdin` object exposed by Node.js inherits these methods
|
||||
* from `Socket`, but the lightweight stub used in tests only extends
|
||||
* `EventEmitter`. Ink calls the two methods when enabling/disabling raw
|
||||
* mode, so make them harmless no‑ops when they're absent to avoid runtime
|
||||
* failures during unit tests.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
// Cast through `unknown` ➜ `any` to avoid the `TS2352`/`TS4111` complaints
|
||||
// when augmenting the prototype with the stubbed `ref`/`unref` methods in the
|
||||
// test environment. Using `any` here is acceptable because we purposefully
|
||||
// monkey‑patch internals of Node's `EventEmitter` solely for the benefit of
|
||||
// Ink's stdin stub – type‑safety is not a primary concern at this boundary.
|
||||
//
|
||||
const proto: any = EventEmitter.prototype;
|
||||
|
||||
if (typeof proto["ref"] !== "function") {
|
||||
proto["ref"] = function ref() {};
|
||||
}
|
||||
if (typeof proto["unref"] !== "function") {
|
||||
proto["unref"] = function unref() {};
|
||||
}
|
||||
|
||||
/*
|
||||
* The `ink-testing-library` stub emits only a `data` event when its `stdin`
|
||||
* mock receives `write()` calls. Ink, however, listens for `readable` and
|
||||
* uses the `read()` method to fetch the buffered chunk. Bridge the gap by
|
||||
* hooking into `EventEmitter.emit` so that every `data` emission also:
|
||||
* 1. Buffers the chunk for a subsequent `read()` call, and
|
||||
* 2. Triggers a `readable` event, matching the contract expected by Ink.
|
||||
*/
|
||||
|
||||
// Preserve original emit to avoid infinite recursion.
|
||||
// eslint‑disable‑next‑line @typescript-eslint/no‑unsafe‑assignment
|
||||
const originalEmit = proto["emit"] as (...args: Array<any>) => boolean;
|
||||
|
||||
proto["emit"] = function patchedEmit(
|
||||
this: any,
|
||||
event: string,
|
||||
...args: Array<any>
|
||||
): boolean {
|
||||
if (event === "data") {
|
||||
const chunk = args[0] as string;
|
||||
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor:stdin] data", JSON.stringify(chunk));
|
||||
}
|
||||
// Store carriage returns as‑is so that Ink can distinguish between plain
|
||||
// <Enter> ("\r") and a bare line‑feed ("\n"). This matters because Ink's
|
||||
// `parseKeypress` treats "\r" as key.name === "return", whereas "\n" maps
|
||||
// to "enter" – allowing us to differentiate between plain Enter (submit)
|
||||
// and Shift+Enter (insert newline) inside `useInput`.
|
||||
|
||||
// Identify the lightweight testing stub: lacks `.read()` but exposes
|
||||
// `.setRawMode()` and `isTTY` similar to the real TTY stream.
|
||||
if (
|
||||
!(this as any)._inkIsStub &&
|
||||
typeof (this as any).setRawMode === "function" &&
|
||||
typeof (this as any).isTTY === "boolean" &&
|
||||
typeof (this as any).read !== "function"
|
||||
) {
|
||||
(this as any)._inkIsStub = true;
|
||||
|
||||
// Provide a minimal `read()` shim so Ink can pull queued chunks.
|
||||
(this as any).read = function read() {
|
||||
const ret = (this as any)._inkBuffered ?? null;
|
||||
(this as any)._inkBuffered = null;
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor:stdin.read]", JSON.stringify(ret));
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
|
||||
if ((this as any)._inkIsStub) {
|
||||
// Buffer the payload so that `read()` can synchronously retrieve it.
|
||||
if (typeof (this as any)._inkBuffered === "string") {
|
||||
(this as any)._inkBuffered += chunk;
|
||||
} else {
|
||||
(this as any)._inkBuffered = chunk;
|
||||
}
|
||||
|
||||
// Notify listeners that data is ready in a way Ink understands.
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"[MultilineTextEditor:stdin] -> readable",
|
||||
JSON.stringify(chunk),
|
||||
);
|
||||
}
|
||||
originalEmit.call(this, "readable");
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the original event.
|
||||
return originalEmit.call(this, event, ...args);
|
||||
};
|
||||
|
||||
export interface MultilineTextEditorProps {
|
||||
// Initial contents.
|
||||
readonly initialText?: string;
|
||||
|
||||
// Visible width.
|
||||
readonly width?: number;
|
||||
|
||||
// Visible height.
|
||||
readonly height?: number;
|
||||
|
||||
// Called when the user submits (plain <Enter> key).
|
||||
readonly onSubmit?: (text: string) => void;
|
||||
|
||||
// Capture keyboard input.
|
||||
readonly focus?: boolean;
|
||||
|
||||
// Called when the internal text buffer updates.
|
||||
readonly onChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
// Expose a minimal imperative API so parent components (e.g. TerminalChatInput)
|
||||
// can query the caret position to implement behaviours like history
|
||||
// navigation that depend on whether the cursor sits on the first/last line.
|
||||
export interface MultilineTextEditorHandle {
|
||||
/** Current caret row */
|
||||
getRow(): number;
|
||||
/** Current caret column */
|
||||
getCol(): number;
|
||||
/** Total number of lines in the buffer */
|
||||
getLineCount(): number;
|
||||
/** Helper: caret is on the very first row */
|
||||
isCursorAtFirstRow(): boolean;
|
||||
/** Helper: caret is on the very last row */
|
||||
isCursorAtLastRow(): boolean;
|
||||
/** Full text contents */
|
||||
getText(): string;
|
||||
}
|
||||
|
||||
const MultilineTextEditorInner = (
|
||||
{
|
||||
initialText = "",
|
||||
// Width can be provided by the caller. When omitted we fall back to the
|
||||
// current terminal size (minus some padding handled by `useTerminalSize`).
|
||||
width,
|
||||
height = 10,
|
||||
onSubmit,
|
||||
focus = true,
|
||||
onChange,
|
||||
}: MultilineTextEditorProps,
|
||||
ref: React.Ref<MultilineTextEditorHandle | null>,
|
||||
): React.ReactElement => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buffer = useRef(new TextBuffer(initialText));
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Keep track of the current terminal size so that the editor grows/shrinks
|
||||
// with the window. `useTerminalSize` already subtracts a small horizontal
|
||||
// padding so that we don't butt up right against the edge.
|
||||
const terminalSize = useTerminalSize();
|
||||
|
||||
// If the caller didn't specify a width we dynamically choose one based on
|
||||
// the terminal's current column count. We still enforce a reasonable
|
||||
// minimum so that the UI never becomes unusably small.
|
||||
const effectiveWidth = Math.max(20, width ?? terminalSize.columns);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External editor integration helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Access to stdin so we can toggle raw‑mode while the external editor is
|
||||
// in control of the terminal.
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
/**
|
||||
* Launch the user's preferred $EDITOR, blocking until they close it, then
|
||||
* reload the edited file back into the in‑memory TextBuffer. The heavy
|
||||
* work is delegated to `TextBuffer.openInExternalEditor`, but we are
|
||||
* responsible for temporarily *disabling* raw mode so the child process can
|
||||
* interact with the TTY normally.
|
||||
*/
|
||||
const openExternalEditor = React.useCallback(async () => {
|
||||
// Preserve the current raw‑mode setting so we can restore it afterwards.
|
||||
const wasRaw = stdin?.isRaw ?? false;
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
await buffer.current.openInExternalEditor();
|
||||
} catch (err) {
|
||||
// Surface the error so it doesn't fail silently – for now we log to
|
||||
// stderr. In the future this could surface a toast / overlay.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[MultilineTextEditor] external editor error", err);
|
||||
} finally {
|
||||
if (wasRaw) {
|
||||
setRawMode?.(true);
|
||||
}
|
||||
// Force a re‑render so the component reflects the mutated buffer.
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
}, [buffer, stdin, setRawMode]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard handling.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single‑step editor shortcut: Ctrl+X or Ctrl+E
|
||||
// Treat both true Ctrl+Key combinations *and* raw control codes so that
|
||||
// the shortcut works consistently in real terminals (raw‑mode) and the
|
||||
// ink‑testing‑library stub which delivers only the raw byte (e.g. 0x05
|
||||
// for Ctrl‑E) without setting `key.ctrl`.
|
||||
const isCtrlX =
|
||||
(key.ctrl && (input === "x" || input === "\x18")) || input === "\x18";
|
||||
const isCtrlE =
|
||||
(key.ctrl && (input === "e" || input === "\x05")) ||
|
||||
input === "\x05" ||
|
||||
(!key.ctrl &&
|
||||
input === "e" &&
|
||||
input.length === 1 &&
|
||||
input.charCodeAt(0) === 5);
|
||||
if (isCtrlX || isCtrlE) {
|
||||
openExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor] event", { input, key });
|
||||
}
|
||||
|
||||
// 1) CSI‑u / modifyOtherKeys (Ink strips initial ESC, so we start with '[')
|
||||
if (input.startsWith("[") && input.endsWith("u")) {
|
||||
const m = input.match(/^\[([0-9]+);([0-9]+)u$/);
|
||||
if (m && m[1] === "13") {
|
||||
const mod = Number(m[2]);
|
||||
// In xterm's encoding: bit‑1 (value 2) is Shift. Everything >1 that
|
||||
// isn't exactly 1 means some modifier was held. We treat *shift
|
||||
// present* (2,4,6,8) as newline; plain (1) as submit.
|
||||
|
||||
// Xterm encodes modifier keys in `mod` – bit‑2 (value 4) indicates
|
||||
// that Ctrl was held. We avoid the `&` bitwise operator (disallowed
|
||||
// by our ESLint config) by using arithmetic instead.
|
||||
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
|
||||
if (hasCtrl) {
|
||||
if (onSubmit) {
|
||||
onSubmit(buffer.current.getText());
|
||||
}
|
||||
} else {
|
||||
// Any variant without Ctrl just inserts newline (Shift, Alt, none)
|
||||
buffer.current.newline();
|
||||
}
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Single‑byte control chars ------------------------------------------------
|
||||
if (input === "\n") {
|
||||
// Ctrl+J or pasted newline → insert newline.
|
||||
buffer.current.newline();
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === "\r") {
|
||||
// Plain Enter – submit (works on all basic terminals).
|
||||
if (onSubmit) {
|
||||
onSubmit(buffer.current.getText());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Let <Esc> fall through so the parent handler (if any) can act on it.
|
||||
|
||||
// Delegate remaining keys to our pure TextBuffer
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor] key event", { input, key });
|
||||
}
|
||||
|
||||
const modified = buffer.current.handleInput(
|
||||
input,
|
||||
key as Record<string, boolean>,
|
||||
{ height, width: effectiveWidth },
|
||||
);
|
||||
if (modified) {
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
const newText = buffer.current.getText();
|
||||
if (onChange) {
|
||||
onChange(newText);
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Imperative handle – expose a read‑only view of caret & buffer geometry */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getRow: () => buffer.current.getCursor()[0],
|
||||
getCol: () => buffer.current.getCursor()[1],
|
||||
getLineCount: () => buffer.current.getText().split("\n").length,
|
||||
isCursorAtFirstRow: () => buffer.current.getCursor()[0] === 0,
|
||||
isCursorAtLastRow: () => {
|
||||
const [row] = buffer.current.getCursor();
|
||||
const lineCount = buffer.current.getText().split("\n").length;
|
||||
return row === lineCount - 1;
|
||||
},
|
||||
getText: () => buffer.current.getText(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Read everything from the buffer
|
||||
const visibleLines = buffer.current.getVisibleLines({
|
||||
height,
|
||||
width: effectiveWidth,
|
||||
});
|
||||
const [cursorRow, cursorCol] = buffer.current.getCursor();
|
||||
const scrollRow = (buffer.current as any).scrollRow as number;
|
||||
const scrollCol = (buffer.current as any).scrollCol as number;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={version}>
|
||||
{visibleLines.map((lineText, idx) => {
|
||||
const absoluteRow = scrollRow + idx;
|
||||
|
||||
// apply horizontal slice
|
||||
let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
|
||||
if (display.length < effectiveWidth) {
|
||||
display = display.padEnd(effectiveWidth, " ");
|
||||
}
|
||||
|
||||
// Highlight the *character under the caret* (i.e. the one immediately
|
||||
// to the right of the insertion position) so that the block cursor
|
||||
// visually matches the logical caret location. This makes the
|
||||
// highlighted glyph the one that would be replaced by `insert()` and
|
||||
// *not* the one that would be removed by `backspace()`.
|
||||
|
||||
if (absoluteRow === cursorRow) {
|
||||
const relativeCol = cursorCol - scrollCol;
|
||||
const highlightCol = relativeCol;
|
||||
|
||||
if (highlightCol >= 0 && highlightCol < effectiveWidth) {
|
||||
const charToHighlight = display[highlightCol] || " ";
|
||||
const highlighted = chalk.inverse(charToHighlight);
|
||||
display =
|
||||
display.slice(0, highlightCol) +
|
||||
highlighted +
|
||||
display.slice(highlightCol + 1);
|
||||
} else if (relativeCol === effectiveWidth) {
|
||||
// Caret sits just past the right edge; show a block cursor in the
|
||||
// gutter so the user still sees it.
|
||||
display = display.slice(0, effectiveWidth - 1) + chalk.inverse(" ");
|
||||
}
|
||||
}
|
||||
|
||||
return <Text key={idx}>{display}</Text>;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MultilineTextEditor = React.forwardRef(MultilineTextEditorInner);
|
||||
|
||||
export default MultilineTextEditor;
|
||||
172
codex-cli/src/components/chat/terminal-chat-command-review.tsx
Normal file
172
codex-cli/src/components/chat/terminal-chat-command-review.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ReviewDecision } from "../../utils/agent/review";
|
||||
// TODO: figure out why `cli-spinners` fails on Node v20.9.0
|
||||
// which is why we have to do this in the first place
|
||||
//
|
||||
// @ts-expect-error select.js is JavaScript and has no types
|
||||
import { Select } from "../vendor/ink-select/select";
|
||||
import TextInput from "../vendor/ink-text-input";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React from "react";
|
||||
|
||||
// default deny‑reason:
|
||||
const DEFAULT_DENY_MESSAGE =
|
||||
"Don't do that, but keep trying to fix the problem";
|
||||
|
||||
export function TerminalChatCommandReview({
|
||||
confirmationPrompt,
|
||||
onReviewCommand,
|
||||
}: {
|
||||
confirmationPrompt: React.ReactNode;
|
||||
onReviewCommand: (decision: ReviewDecision, customMessage?: string) => void;
|
||||
}): React.ReactElement {
|
||||
const [mode, setMode] = React.useState<"select" | "input">("select");
|
||||
const [msg, setMsg] = React.useState<string>("");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Determine whether the "always approve" option should be displayed. We
|
||||
// only hide it for the special `apply_patch` command since approving those
|
||||
// permanently would bypass the user's review of future file modifications.
|
||||
// The information is embedded in the `confirmationPrompt` React element –
|
||||
// we inspect the `commandForDisplay` prop exposed by
|
||||
// <TerminalChatToolCallCommand/> to extract the base command.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const showAlwaysApprove = React.useMemo(() => {
|
||||
if (
|
||||
React.isValidElement(confirmationPrompt) &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (confirmationPrompt as any).props?.commandForDisplay === "string"
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const command: string = (confirmationPrompt as any).props
|
||||
.commandForDisplay;
|
||||
// Grab the first token of the first line – that corresponds to the base
|
||||
// command even when the string contains embedded newlines (e.g. diffs).
|
||||
const baseCmd = command.split("\n")[0]?.trim().split(/\s+/)[0] ?? "";
|
||||
return baseCmd !== "apply_patch";
|
||||
}
|
||||
// Default to showing the option when we cannot reliably detect the base
|
||||
// command.
|
||||
return true;
|
||||
}, [confirmationPrompt]);
|
||||
|
||||
// Memoize the list of selectable options to avoid recreating the array on
|
||||
// every render. This keeps <Select/> stable and prevents unnecessary work
|
||||
// inside Ink.
|
||||
const approvalOptions = React.useMemo(() => {
|
||||
const opts: Array<
|
||||
| { label: string; value: ReviewDecision }
|
||||
| { label: string; value: "edit" }
|
||||
> = [
|
||||
{
|
||||
label: "Yes (y)",
|
||||
value: ReviewDecision.YES,
|
||||
},
|
||||
];
|
||||
|
||||
if (showAlwaysApprove) {
|
||||
opts.push({
|
||||
label: "Yes, always approve this exact command for this session (a)",
|
||||
value: ReviewDecision.ALWAYS,
|
||||
});
|
||||
}
|
||||
|
||||
opts.push(
|
||||
{
|
||||
label: "Edit or give feedback (e)",
|
||||
value: "edit",
|
||||
},
|
||||
{
|
||||
label: "No, and keep going (n)",
|
||||
value: ReviewDecision.NO_CONTINUE,
|
||||
},
|
||||
{
|
||||
label: "No, and stop for now (esc)",
|
||||
value: ReviewDecision.NO_EXIT,
|
||||
},
|
||||
);
|
||||
|
||||
return opts;
|
||||
}, [showAlwaysApprove]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (mode === "select") {
|
||||
if (input === "y") {
|
||||
onReviewCommand(ReviewDecision.YES);
|
||||
} else if (input === "e") {
|
||||
setMode("input");
|
||||
} else if (input === "n") {
|
||||
onReviewCommand(
|
||||
ReviewDecision.NO_CONTINUE,
|
||||
"Don't do that, keep going though",
|
||||
);
|
||||
} else if (input === "a" && showAlwaysApprove) {
|
||||
onReviewCommand(ReviewDecision.ALWAYS);
|
||||
} else if (key.escape) {
|
||||
onReviewCommand(ReviewDecision.NO_EXIT);
|
||||
}
|
||||
} else {
|
||||
// text entry mode
|
||||
if (key.return) {
|
||||
// if user hit enter on empty msg, fall back to DEFAULT_DENY_MESSAGE
|
||||
const custom = msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg;
|
||||
onReviewCommand(ReviewDecision.NO_CONTINUE, custom);
|
||||
} else if (key.escape) {
|
||||
// treat escape as denial with default message as well
|
||||
onReviewCommand(
|
||||
ReviewDecision.NO_CONTINUE,
|
||||
msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} borderStyle="round" marginTop={1}>
|
||||
{confirmationPrompt}
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{mode === "select" ? (
|
||||
<>
|
||||
<Text>Allow command?</Text>
|
||||
<Box paddingX={2} flexDirection="column" gap={1}>
|
||||
<Select
|
||||
onChange={(value: ReviewDecision | "edit") => {
|
||||
if (value === "edit") {
|
||||
setMode("input");
|
||||
} else {
|
||||
onReviewCommand(value);
|
||||
}
|
||||
}}
|
||||
options={approvalOptions}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>Give the model feedback (↵ to submit):</Text>
|
||||
<Box borderStyle="round">
|
||||
<Box paddingX={1}>
|
||||
<TextInput
|
||||
value={msg}
|
||||
onChange={setMsg}
|
||||
placeholder="type a reason"
|
||||
showCursor
|
||||
focus
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{msg.trim() === "" && (
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
default:
|
||||
<Text>{DEFAULT_DENY_MESSAGE}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
173
codex-cli/src/components/chat/terminal-chat-input-thinking.tsx
Normal file
173
codex-cli/src/components/chat/terminal-chat-input-thinking.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import { Box, Text, useInput, useStdin } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const thinkingTexts = ["Thinking"]; /* [
|
||||
"Consulting the rubber duck",
|
||||
"Maximizing paperclips",
|
||||
"Reticulating splines",
|
||||
"Immanentizing the Eschaton",
|
||||
"Thinking",
|
||||
"Thinking about thinking",
|
||||
"Spinning in circles",
|
||||
"Counting dust specks",
|
||||
"Updating priors",
|
||||
"Feeding the utility monster",
|
||||
"Taking off",
|
||||
"Wireheading",
|
||||
"Counting to infinity",
|
||||
"Staring into the Basilisk",
|
||||
"Negotiationing acausal trades",
|
||||
"Searching the library of babel",
|
||||
"Multiplying matrices",
|
||||
"Solving the halting problem",
|
||||
"Counting grains of sand",
|
||||
"Simulating a simulation",
|
||||
"Asking the oracle",
|
||||
"Detangling qubits",
|
||||
"Reading tea leaves",
|
||||
"Pondering universal love and transcendant joy",
|
||||
"Feeling the AGI",
|
||||
"Shaving the yak",
|
||||
"Escaping local minima",
|
||||
"Pruning the search tree",
|
||||
"Descending the gradient",
|
||||
"Bikeshedding",
|
||||
"Securing funding",
|
||||
"Rewriting in Rust",
|
||||
"Engaging infinite improbability drive",
|
||||
"Clapping with one hand",
|
||||
"Synthesizing",
|
||||
"Rebasing thesis onto antithesis",
|
||||
"Transcending the loop",
|
||||
"Frogeposting",
|
||||
"Summoning",
|
||||
"Peeking beyond the veil",
|
||||
"Seeking",
|
||||
"Entering deep thought",
|
||||
"Meditating",
|
||||
"Decomposing",
|
||||
"Creating",
|
||||
"Beseeching the machine spirit",
|
||||
"Calibrating moral compass",
|
||||
"Collapsing the wave function",
|
||||
"Doodling",
|
||||
"Translating whale song",
|
||||
"Whispering to silicon",
|
||||
"Looking for semicolons",
|
||||
"Asking ChatGPT",
|
||||
"Bargaining with entropy",
|
||||
"Channeling",
|
||||
"Cooking",
|
||||
"Parrotting stochastically",
|
||||
]; */
|
||||
|
||||
export default function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
const [thinkingText, setThinkingText] = useState(
|
||||
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
|
||||
);
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
setThinkingText((prev) => {
|
||||
let next = prev;
|
||||
if (thinkingTexts.length > 1) {
|
||||
while (next === prev) {
|
||||
next =
|
||||
thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
active ? 30000 : null,
|
||||
);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{thinkingText}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
409
codex-cli/src/components/chat/terminal-chat-input.tsx
Normal file
409
codex-cli/src/components/chat/terminal-chat-input.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
ResponseItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import TextInput from "../vendor/ink-text-input.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React, { useCallback, useState, Fragment } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const suggestions = [
|
||||
"explain this codebase to me",
|
||||
"fix any build errors",
|
||||
"are there any bugs in my code?",
|
||||
];
|
||||
|
||||
export default function TerminalChatInput({
|
||||
isNew,
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
contextLeftPercent,
|
||||
openOverlay,
|
||||
openModelOverlay,
|
||||
openApprovalOverlay,
|
||||
openHelpOverlay,
|
||||
interruptAgent,
|
||||
active,
|
||||
}: {
|
||||
isNew: boolean;
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) => void;
|
||||
setLastResponseId: (lastResponseId: string) => void;
|
||||
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
|
||||
contextLeftPercent: number;
|
||||
openOverlay: () => void;
|
||||
openModelOverlay: () => void;
|
||||
openApprovalOverlay: () => void;
|
||||
openHelpOverlay: () => void;
|
||||
interruptAgent: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const app = useApp();
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<Array<string>>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
|
||||
useInput(
|
||||
(_input, _key) => {
|
||||
if (!confirmationPrompt && !loading) {
|
||||
if (_key.upArrow) {
|
||||
if (history.length > 0) {
|
||||
if (historyIndex == null) {
|
||||
setDraftInput(input);
|
||||
}
|
||||
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_key.downArrow) {
|
||||
if (historyIndex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setInput(draftInput);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.trim() === "" && isNew) {
|
||||
if (_key.tab) {
|
||||
setSelectedSuggestion(
|
||||
(s) => (s + (_key.shift ? -1 : 1)) % (suggestions.length + 1),
|
||||
);
|
||||
} else if (selectedSuggestion && _key.return) {
|
||||
const suggestion = suggestions[selectedSuggestion - 1] || "";
|
||||
setInput("");
|
||||
setSelectedSuggestion(0);
|
||||
submitInput([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: suggestion }],
|
||||
type: "message",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (_input === "\u0003" || (_input === "c" && _key.ctrl)) {
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const inputValue = value.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/history") {
|
||||
setInput("");
|
||||
openOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/help") {
|
||||
setInput("");
|
||||
openHelpOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/model")) {
|
||||
setInput("");
|
||||
openModelOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/approval")) {
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") {
|
||||
setInput("");
|
||||
// wait one 60ms frame
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
return;
|
||||
} else if (inputValue === "/clear" || inputValue === "clear") {
|
||||
setInput("");
|
||||
setSessionId("");
|
||||
setLastResponseId("");
|
||||
clearTerminal();
|
||||
|
||||
// Emit a system message to confirm the clear action. We *append*
|
||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `clear-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: "Context cleared" }],
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const images: Array<string> = [];
|
||||
const text = inputValue
|
||||
.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
const inputItem = await createInputItem(text, images);
|
||||
submitInput([inputItem]);
|
||||
setHistory((prev) => {
|
||||
if (prev[prev.length - 1] === value) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, value];
|
||||
});
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
setSelectedSuggestion(0);
|
||||
setInput("");
|
||||
},
|
||||
[
|
||||
setInput,
|
||||
submitInput,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
app,
|
||||
setHistory,
|
||||
setHistoryIndex,
|
||||
openOverlay,
|
||||
openApprovalOverlay,
|
||||
openModelOverlay,
|
||||
openHelpOverlay,
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmationPrompt) {
|
||||
return (
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round">
|
||||
{loading ? (
|
||||
<TerminalChatInputThinking
|
||||
onInterrupt={interruptAgent}
|
||||
active={active}
|
||||
/>
|
||||
) : (
|
||||
<Box paddingX={1}>
|
||||
<TextInput
|
||||
focus={active}
|
||||
placeholder={
|
||||
selectedSuggestion
|
||||
? `"${suggestions[selectedSuggestion - 1]}"`
|
||||
: "send a message" +
|
||||
(isNew ? " or press tab to select a suggestion" : "")
|
||||
}
|
||||
showCursor
|
||||
value={input}
|
||||
onChange={(value) => {
|
||||
setDraftInput(value);
|
||||
if (historyIndex != null) {
|
||||
setHistoryIndex(null);
|
||||
}
|
||||
setInput(value);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
{isNew && !input ? (
|
||||
<>
|
||||
try:{" "}
|
||||
{suggestions.map((m, key) => (
|
||||
<Fragment key={key}>
|
||||
{key !== 0 ? " | " : ""}
|
||||
<Text
|
||||
backgroundColor={
|
||||
key + 1 === selectedSuggestion ? "blackBright" : ""
|
||||
}
|
||||
>
|
||||
{m}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
|
||||
for commands | press enter to send
|
||||
{contextLeftPercent < 25 && (
|
||||
<>
|
||||
{" — "}
|
||||
<Text color="red">
|
||||
{Math.round(contextLeftPercent)}% context left
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Raw stdin listener to catch the case where the terminal delivers two
|
||||
// consecutive ESC bytes ("\x1B\x1B") in a *single* chunk. Ink's `useInput`
|
||||
// collapses that sequence into one key event, so the regular two‑step
|
||||
// handler above never sees the second press. By inspecting the raw data
|
||||
// we can identify this special case and trigger the interrupt while still
|
||||
// requiring a double press for the normal single‑byte ESC events.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure raw mode – already enabled by Ink when the component has focus,
|
||||
// but called defensively in case that assumption ever changes.
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return; // already awaiting a second explicit press
|
||||
}
|
||||
|
||||
// Handle both Buffer and string forms.
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
// Treat as the first Escape press – prompt the user for confirmation.
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
// Cycle the "Thinking…" animation dots.
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
// Listen for the escape key to allow the user to interrupt the current
|
||||
// operation. We require two presses within a short window (1.5s) to avoid
|
||||
// accidental cancellations.
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>Thinking{dots}</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
506
codex-cli/src/components/chat/terminal-chat-new-input.tsx
Normal file
506
codex-cli/src/components/chat/terminal-chat-new-input.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { MultilineTextEditorHandle } from "./multiline-editor";
|
||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
ResponseItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import MultilineTextEditor from "./multiline-editor";
|
||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React, { useCallback, useState, Fragment } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const suggestions = [
|
||||
"explain this codebase to me",
|
||||
"fix any build errors",
|
||||
"are there any bugs in my code?",
|
||||
];
|
||||
|
||||
const typeHelpText = `ctrl+c to exit | "/clear" to reset context | "/help" for commands | ↑↓ to recall history | ctrl+x to open external editor | enter to send`;
|
||||
|
||||
// Enable verbose logging for the history‑navigation logic when the
|
||||
// DEBUG_TCI environment variable is truthy. The traces help while debugging
|
||||
// unit‑test failures but remain silent in production.
|
||||
const DEBUG_HIST =
|
||||
process.env["DEBUG_TCI"] === "1" || process.env["DEBUG_TCI"] === "true";
|
||||
|
||||
const thinkingTexts = ["Thinking"]; /* [
|
||||
"Consulting the rubber duck",
|
||||
"Maximizing paperclips",
|
||||
"Reticulating splines",
|
||||
"Immanentizing the Eschaton",
|
||||
"Thinking",
|
||||
"Thinking about thinking",
|
||||
"Spinning in circles",
|
||||
"Counting dust specks",
|
||||
"Updating priors",
|
||||
"Feeding the utility monster",
|
||||
"Taking off",
|
||||
"Wireheading",
|
||||
"Counting to infinity",
|
||||
"Staring into the Basilisk",
|
||||
"Running acausal tariff negotiations",
|
||||
"Searching the library of babel",
|
||||
"Multiplying matrices",
|
||||
"Solving the halting problem",
|
||||
"Counting grains of sand",
|
||||
"Simulating a simulation",
|
||||
"Asking the oracle",
|
||||
"Detangling qubits",
|
||||
"Reading tea leaves",
|
||||
"Pondering universal love and transcendant joy",
|
||||
"Feeling the AGI",
|
||||
"Shaving the yak",
|
||||
"Escaping local minima",
|
||||
"Pruning the search tree",
|
||||
"Descending the gradient",
|
||||
"Painting the bikeshed",
|
||||
"Securing funding",
|
||||
]; */
|
||||
|
||||
export default function TerminalChatInput({
|
||||
isNew: _isNew,
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
contextLeftPercent,
|
||||
openOverlay,
|
||||
openModelOverlay,
|
||||
openApprovalOverlay,
|
||||
openHelpOverlay,
|
||||
interruptAgent,
|
||||
active,
|
||||
}: {
|
||||
isNew: boolean;
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) => void;
|
||||
setLastResponseId: (lastResponseId: string) => void;
|
||||
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
|
||||
contextLeftPercent: number;
|
||||
openOverlay: () => void;
|
||||
openModelOverlay: () => void;
|
||||
openApprovalOverlay: () => void;
|
||||
openHelpOverlay: () => void;
|
||||
interruptAgent: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const app = useApp();
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<Array<string>>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
// Multiline text editor is now the default input mode. We keep an
|
||||
// incremental `editorKey` so that we can force‑remount the component and
|
||||
// thus reset its internal buffer after each successful submit.
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
|
||||
// Imperative handle from the multiline editor so we can query caret position
|
||||
const editorRef = React.useRef<MultilineTextEditorHandle | null>(null);
|
||||
|
||||
// Track the caret row across keystrokes so we can tell whether the cursor
|
||||
// was *already* on the first/last line before the curren`t key event. This
|
||||
// lets us distinguish between a normal vertical navigation (e.g. moving
|
||||
// from row 1 → row 0 inside a multi‑line draft) and an attempt to navigate
|
||||
// the chat history (pressing ↑ again while already at row 0).
|
||||
const prevCursorRow = React.useRef<number | null>(null);
|
||||
|
||||
useInput(
|
||||
(_input, _key) => {
|
||||
if (!confirmationPrompt && !loading) {
|
||||
if (_key.upArrow) {
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] upArrow", {
|
||||
historyIndex,
|
||||
input,
|
||||
cursorRow: editorRef.current?.getRow?.(),
|
||||
});
|
||||
}
|
||||
// Only recall history when the caret was *already* on the very first
|
||||
// row *before* this key‑press. That means the user pressed ↑ while
|
||||
// the cursor sat at the top – mirroring how shells like Bash/zsh
|
||||
// enter history navigation. When the caret starts on a lower line
|
||||
// the first ↑ should merely move it up one row; only a subsequent
|
||||
// press (when we are *still* at row 0) should trigger the recall.
|
||||
|
||||
const cursorRow = editorRef.current?.getRow?.() ?? 0;
|
||||
const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0;
|
||||
|
||||
if (history.length > 0 && cursorRow === 0 && wasAtFirstRow) {
|
||||
if (historyIndex == null) {
|
||||
const currentDraft = editorRef.current?.getText?.() ?? input;
|
||||
setDraftInput(currentDraft);
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] store draft", JSON.stringify(currentDraft));
|
||||
}
|
||||
}
|
||||
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
// Re‑mount the editor so it picks up the new initialText.
|
||||
setEditorKey((k) => k + 1);
|
||||
return; // we handled the key
|
||||
}
|
||||
// Otherwise let the event propagate so the editor moves the caret.
|
||||
}
|
||||
|
||||
if (_key.downArrow) {
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] downArrow", { historyIndex, draftInput, input });
|
||||
}
|
||||
// Only move forward in history when we're already *in* history mode
|
||||
// AND the caret sits on the last line of the buffer (so ↓ within a
|
||||
// multi‑line draft simply moves the caret down).
|
||||
if (historyIndex != null && editorRef.current?.isCursorAtLastRow()) {
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setInput(draftInput);
|
||||
setEditorKey((k) => k + 1);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
setEditorKey((k) => k + 1);
|
||||
}
|
||||
return; // handled
|
||||
}
|
||||
// Otherwise let it propagate.
|
||||
}
|
||||
}
|
||||
|
||||
if (input.trim() === "") {
|
||||
if (_key.tab) {
|
||||
setSelectedSuggestion(
|
||||
(s) => (s + (_key.shift ? -1 : 1)) % (suggestions.length + 1),
|
||||
);
|
||||
} else if (selectedSuggestion && _key.return) {
|
||||
const suggestion = suggestions[selectedSuggestion - 1] || "";
|
||||
setInput("");
|
||||
setSelectedSuggestion(0);
|
||||
submitInput([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: suggestion }],
|
||||
type: "message",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (_input === "\u0003" || (_input === "c" && _key.ctrl)) {
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
// Update the cached cursor position *after* we've potentially handled
|
||||
// the key so that the next event has the correct "previous" reference.
|
||||
prevCursorRow.current = editorRef.current?.getRow?.() ?? null;
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const inputValue = value.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/history") {
|
||||
setInput("");
|
||||
openOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/help") {
|
||||
setInput("");
|
||||
openHelpOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/model")) {
|
||||
setInput("");
|
||||
openModelOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/approval")) {
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") {
|
||||
setInput("");
|
||||
// wait one 60ms frame
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
return;
|
||||
} else if (inputValue === "/clear" || inputValue === "clear") {
|
||||
setInput("");
|
||||
setSessionId("");
|
||||
setLastResponseId("");
|
||||
clearTerminal();
|
||||
|
||||
// Emit a system message to confirm the clear action. We *append*
|
||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `clear-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: "Context cleared" }],
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const images: Array<string> = [];
|
||||
const text = inputValue
|
||||
.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
const inputItem = await createInputItem(text, images);
|
||||
submitInput([inputItem]);
|
||||
setHistory((prev) => {
|
||||
if (prev[prev.length - 1] === value) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, value];
|
||||
});
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
setSelectedSuggestion(0);
|
||||
setInput("");
|
||||
},
|
||||
[
|
||||
setInput,
|
||||
submitInput,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
app,
|
||||
setHistory,
|
||||
setHistoryIndex,
|
||||
openOverlay,
|
||||
openApprovalOverlay,
|
||||
openModelOverlay,
|
||||
openHelpOverlay,
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmationPrompt) {
|
||||
return (
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{loading ? (
|
||||
<Box borderStyle="round">
|
||||
<TerminalChatInputThinking
|
||||
onInterrupt={interruptAgent}
|
||||
active={active}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box borderStyle="round">
|
||||
<MultilineTextEditor
|
||||
ref={editorRef}
|
||||
onChange={(txt: string) => setInput(txt)}
|
||||
key={editorKey}
|
||||
initialText={input}
|
||||
height={8}
|
||||
focus={active}
|
||||
onSubmit={(txt) => {
|
||||
onSubmit(txt);
|
||||
|
||||
setEditorKey((k) => k + 1);
|
||||
|
||||
setInput("");
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
{!input ? (
|
||||
<>
|
||||
try:{" "}
|
||||
{suggestions.map((m, key) => (
|
||||
<Fragment key={key}>
|
||||
{key !== 0 ? " | " : ""}
|
||||
<Text
|
||||
backgroundColor={
|
||||
key + 1 === selectedSuggestion ? "blackBright" : ""
|
||||
}
|
||||
>
|
||||
{m}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{typeHelpText}
|
||||
{contextLeftPercent < 25 && (
|
||||
<>
|
||||
{" — "}
|
||||
<Text color="red">
|
||||
{Math.round(contextLeftPercent)}% context left
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
const [thinkingText] = useState(
|
||||
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Raw stdin listener to catch the case where the terminal delivers two
|
||||
// consecutive ESC bytes ("\x1B\x1B") in a *single* chunk. Ink's `useInput`
|
||||
// collapses that sequence into one key event, so the regular two‑step
|
||||
// handler above never sees the second press. By inspecting the raw data
|
||||
// we can identify this special case and trigger the interrupt while still
|
||||
// requiring a double press for the normal single‑byte ESC events.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure raw mode – already enabled by Ink when the component has focus,
|
||||
// but called defensively in case that assumption ever changes.
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return; // already awaiting a second explicit press
|
||||
}
|
||||
|
||||
// Handle both Buffer and string forms.
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
// Treat as the first Escape press – prompt the user for confirmation.
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{thinkingText}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
Normal file
61
codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { TerminalChatSession } from "../../utils/session.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item";
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export default function TerminalChatPastRollout({
|
||||
session,
|
||||
items,
|
||||
}: {
|
||||
session: TerminalChatSession;
|
||||
items: Array<ResponseItem>;
|
||||
}): React.ReactElement {
|
||||
const { version, id: sessionId, model } = session;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round" paddingX={1} width={64}>
|
||||
<Text>
|
||||
● OpenAI <Text bold>Codex</Text>{" "}
|
||||
<Text dimColor>
|
||||
(research preview) <Text color="blueBright">v{version}</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
width={64}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text>
|
||||
<Text color="magenta">●</Text> localhost{" "}
|
||||
<Text dimColor>· session:</Text>{" "}
|
||||
<Text color="magentaBright" dimColor>
|
||||
{sessionId}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> When / Who:{" "}
|
||||
<Text bold>
|
||||
{session.timestamp} <Text dimColor>/</Text> {session.user}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> model: <Text bold>{model}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{React.useMemo(
|
||||
() =>
|
||||
items.map((item, key) => (
|
||||
<TerminalChatResponseItem key={key} item={item} />
|
||||
)),
|
||||
[items],
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
259
codex-cli/src/components/chat/terminal-chat-response-item.tsx
Normal file
259
codex-cli/src/components/chat/terminal-chat-response-item.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import type { TerminalRendererOptions } from "marked-terminal";
|
||||
import type {
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputMessageItem,
|
||||
ResponseItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseReasoningItem,
|
||||
} from "openai/resources/responses/responses";
|
||||
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size";
|
||||
import { parseToolCall, parseToolCallOutput } from "../../utils/parsers";
|
||||
import chalk, { type ForegroundColorName } from "chalk";
|
||||
import { Box, Text } from "ink";
|
||||
import { parse, setOptions } from "marked";
|
||||
import TerminalRenderer from "marked-terminal";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export default function TerminalChatResponseItem({
|
||||
item,
|
||||
fullStdout = false,
|
||||
}: {
|
||||
item: ResponseItem;
|
||||
fullStdout?: boolean;
|
||||
}): React.ReactElement {
|
||||
switch (item.type) {
|
||||
case "message":
|
||||
return <TerminalChatResponseMessage message={item} />;
|
||||
case "function_call":
|
||||
return <TerminalChatResponseToolCall message={item} />;
|
||||
case "function_call_output":
|
||||
return (
|
||||
<TerminalChatResponseToolCallOutput
|
||||
message={item}
|
||||
fullStdout={fullStdout}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// @ts-expect-error `reasoning` is not in the responses API yet
|
||||
if (item.type === "reasoning") {
|
||||
return <TerminalChatResponseReasoning message={item} />;
|
||||
}
|
||||
|
||||
return <TerminalChatResponseGenericMessage message={item} />;
|
||||
}
|
||||
|
||||
// TODO: this should be part of `ResponseReasoningItem`. Also it doesn't work.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Guess how long the assistant spent "thinking" based on the combined length
|
||||
* of the reasoning summary. The calculation itself is fast, but wrapping it in
|
||||
* `useMemo` in the consuming component ensures it only runs when the
|
||||
* `summary` array actually changes.
|
||||
*/
|
||||
// TODO: use actual thinking time
|
||||
//
|
||||
// function guessThinkingTime(summary: Array<ResponseReasoningItem.Summary>) {
|
||||
// const totalTextLength = summary
|
||||
// .map((t) => t.text.length)
|
||||
// .reduce((a, b) => a + b, summary.length - 1);
|
||||
// return Math.max(1, Math.ceil(totalTextLength / 300));
|
||||
// }
|
||||
|
||||
export function TerminalChatResponseReasoning({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseReasoningItem & { duration_ms?: number };
|
||||
}): React.ReactElement | null {
|
||||
// prefer the real duration if present
|
||||
const thinkingTime = message.duration_ms
|
||||
? Math.round(message.duration_ms / 1000)
|
||||
: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(message.summary || [])
|
||||
.map((t) => t.text.length)
|
||||
.reduce((a, b) => a + b, 0) / 300,
|
||||
),
|
||||
);
|
||||
if (thinkingTime <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box gap={1} flexDirection="column">
|
||||
<Box gap={1}>
|
||||
<Text bold color="magenta">
|
||||
thinking
|
||||
</Text>
|
||||
<Text dimColor>for {thinkingTime}s</Text>
|
||||
</Box>
|
||||
{message.summary?.map((summary, key) => {
|
||||
const s = summary as { headline?: string; text: string };
|
||||
return (
|
||||
<Box key={key} flexDirection="column">
|
||||
{s.headline && <Text bold>{s.headline}</Text>}
|
||||
<Markdown>{s.text}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const colorsByRole: Record<string, ForegroundColorName> = {
|
||||
assistant: "magentaBright",
|
||||
user: "blueBright",
|
||||
};
|
||||
|
||||
function TerminalChatResponseMessage({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseInputMessageItem | ResponseOutputMessage;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={colorsByRole[message.role] || "gray"}>
|
||||
{message.role === "assistant" ? "codex" : message.role}
|
||||
</Text>
|
||||
<Markdown>
|
||||
{message.content
|
||||
.map(
|
||||
(c) =>
|
||||
c.type === "output_text"
|
||||
? c.text
|
||||
: c.type === "refusal"
|
||||
? c.refusal
|
||||
: c.type === "input_text"
|
||||
? c.text
|
||||
: c.type === "input_image"
|
||||
? "<Image>"
|
||||
: c.type === "input_file"
|
||||
? c.filename
|
||||
: "", // unknown content type
|
||||
)
|
||||
.join(" ")}
|
||||
</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatResponseToolCall({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallItem;
|
||||
}) {
|
||||
const details = parseToolCall(message);
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="magentaBright" bold>
|
||||
command
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {details?.cmdReadableText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatResponseToolCallOutput({
|
||||
message,
|
||||
fullStdout,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallOutputItem;
|
||||
fullStdout: boolean;
|
||||
}) {
|
||||
const { output, metadata } = parseToolCallOutput(message.output);
|
||||
const { exit_code, duration_seconds } = metadata;
|
||||
const metadataInfo = useMemo(
|
||||
() =>
|
||||
[
|
||||
typeof exit_code !== "undefined" ? `code: ${exit_code}` : "",
|
||||
typeof duration_seconds !== "undefined"
|
||||
? `duration: ${duration_seconds}s`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
[exit_code, duration_seconds],
|
||||
);
|
||||
let displayedContent = output;
|
||||
if (message.type === "function_call_output" && !fullStdout) {
|
||||
const lines = displayedContent.split("\n");
|
||||
if (lines.length > 4) {
|
||||
const head = lines.slice(0, 4);
|
||||
const remaining = lines.length - 4;
|
||||
displayedContent = [...head, `... (${remaining} more lines)`].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Colorize diff output: lines starting with '-' in red, '+' in green.
|
||||
// This makes patches and other diff‑like stdout easier to read.
|
||||
// We exclude the typical diff file headers ('---', '+++') so they retain
|
||||
// the default color. This is a best‑effort heuristic and should be safe for
|
||||
// non‑diff output – only the very first character of a line is inspected.
|
||||
// -------------------------------------------------------------------------
|
||||
const colorizedContent = displayedContent
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith("+") && !line.startsWith("++")) {
|
||||
return chalk.green(line);
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("--")) {
|
||||
return chalk.red(line);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="magenta" bold>
|
||||
command.stdout{" "}
|
||||
<Text dimColor>{metadataInfo ? `(${metadataInfo})` : ""}</Text>
|
||||
</Text>
|
||||
<Text dimColor>{colorizedContent}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalChatResponseGenericMessage({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseItem;
|
||||
}): React.ReactElement {
|
||||
return <Text>{JSON.stringify(message, null, 2)}</Text>;
|
||||
}
|
||||
|
||||
export type MarkdownProps = TerminalRendererOptions & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
...options
|
||||
}: MarkdownProps): React.ReactElement {
|
||||
const size = useTerminalSize();
|
||||
|
||||
const rendered = React.useMemo(() => {
|
||||
// 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();
|
||||
|
||||
// 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]);
|
||||
|
||||
return <Text>{rendered}</Text>;
|
||||
}
|
||||
106
codex-cli/src/components/chat/terminal-chat-tool-call-item.tsx
Normal file
106
codex-cli/src/components/chat/terminal-chat-tool-call-item.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { shortenPath } from "../../utils/short-path";
|
||||
import { parseApplyPatch } from "@lib/parse-apply-patch";
|
||||
import chalk from "chalk";
|
||||
import { Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export function TerminalChatToolCallCommand({
|
||||
commandForDisplay,
|
||||
}: {
|
||||
commandForDisplay: string;
|
||||
}): React.ReactElement {
|
||||
// -------------------------------------------------------------------------
|
||||
// Colorize diff output inside the command preview: we detect individual
|
||||
// lines that begin with '+' or '-' (excluding the typical diff headers like
|
||||
// '+++', '---', '++', '--') and apply green/red coloring. This mirrors
|
||||
// how Git shows diffs and makes the patch easier to review.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const colorizedCommand = commandForDisplay
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith("+") && !line.startsWith("++")) {
|
||||
return chalk.green(line);
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("--")) {
|
||||
return chalk.red(line);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text bold>Shell Command</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {colorizedCommand}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalChatToolCallApplyPatch({
|
||||
commandForDisplay,
|
||||
patch,
|
||||
}: {
|
||||
commandForDisplay: string;
|
||||
patch: string;
|
||||
}): React.ReactElement {
|
||||
const ops = React.useMemo(() => parseApplyPatch(patch), [patch]);
|
||||
const firstOp = ops?.[0];
|
||||
|
||||
const title = React.useMemo(() => {
|
||||
if (!firstOp) {
|
||||
return "";
|
||||
}
|
||||
return capitalize(firstOp.type);
|
||||
}, [firstOp]);
|
||||
|
||||
const filePath = React.useMemo(() => {
|
||||
if (!firstOp) {
|
||||
return "";
|
||||
}
|
||||
return shortenPath(firstOp.path || ".");
|
||||
}, [firstOp]);
|
||||
|
||||
if (ops == null) {
|
||||
return (
|
||||
<>
|
||||
<Text bold color="red">
|
||||
Invalid Patch
|
||||
</Text>
|
||||
<Text color="red" dimColor>
|
||||
The provided patch command is invalid.
|
||||
</Text>
|
||||
<Text dimColor>{commandForDisplay}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!firstOp) {
|
||||
return (
|
||||
<>
|
||||
<Text bold color="yellow">
|
||||
Empty Patch
|
||||
</Text>
|
||||
<Text color="yellow" dimColor>
|
||||
No operations found in the patch command.
|
||||
</Text>
|
||||
<Text dimColor>{commandForDisplay}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
<Text bold>{title}</Text> <Text dimColor>{filePath}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {commandForDisplay}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
113
codex-cli/src/components/chat/terminal-chat-utils.ts
Normal file
113
codex-cli/src/components/chat/terminal-chat-utils.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { approximateTokensUsed } from "../../utils/approximate-tokens-used.js";
|
||||
|
||||
/**
|
||||
* Type‑guard that narrows a {@link ResponseItem} to one that represents a
|
||||
* user‑authored message. The OpenAI SDK represents both input *and* output
|
||||
* messages with a discriminated union where:
|
||||
* • `type` is the string literal "message" and
|
||||
* • `role` is one of "user" | "assistant" | "system" | "developer".
|
||||
*
|
||||
* For the purposes of de‑duplication we only care about *user* messages so we
|
||||
* detect those here in a single, reusable helper.
|
||||
*/
|
||||
function isUserMessage(
|
||||
item: ResponseItem,
|
||||
): item is ResponseItem & { type: "message"; role: "user"; content: unknown } {
|
||||
return item.type === "message" && (item as { role?: string }).role === "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum context length (in tokens) for a given model.
|
||||
* These numbers are best‑effort guesses and provide a basis for UI percentages.
|
||||
*/
|
||||
export function maxTokensForModel(model: string): number {
|
||||
const lower = model.toLowerCase();
|
||||
if (lower.includes("32k")) {
|
||||
return 32000;
|
||||
}
|
||||
if (lower.includes("16k")) {
|
||||
return 16000;
|
||||
}
|
||||
if (lower.includes("8k")) {
|
||||
return 8000;
|
||||
}
|
||||
if (lower.includes("4k")) {
|
||||
return 4000;
|
||||
}
|
||||
// Default to 128k for newer long‑context models
|
||||
return 128000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage of tokens remaining in context for a model.
|
||||
*/
|
||||
export function calculateContextPercentRemaining(
|
||||
items: Array<ResponseItem>,
|
||||
model: string,
|
||||
): number {
|
||||
const used = approximateTokensUsed(items);
|
||||
const max = maxTokensForModel(model);
|
||||
const remaining = Math.max(0, max - used);
|
||||
return (remaining / max) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate the stream of {@link ResponseItem}s before they are persisted in
|
||||
* component state.
|
||||
*
|
||||
* Historically we used the (optional) {@code id} field returned by the
|
||||
* OpenAI streaming API as the primary key: the first occurrence of any given
|
||||
* {@code id} “won” and subsequent duplicates were dropped. In practice this
|
||||
* proved brittle because locally‑generated user messages don’t include an
|
||||
* {@code id}. The result was that if a user quickly pressed <Enter> twice the
|
||||
* exact same message would appear twice in the transcript.
|
||||
*
|
||||
* The new rules are therefore:
|
||||
* 1. If a {@link ResponseItem} has an {@code id} keep only the *first*
|
||||
* occurrence of that {@code id} (this retains the previous behaviour for
|
||||
* assistant / tool messages).
|
||||
* 2. Additionally, collapse *consecutive* user messages with identical
|
||||
* content. Two messages are considered identical when their serialized
|
||||
* {@code content} array matches exactly. We purposefully restrict this
|
||||
* to **adjacent** duplicates so that legitimately repeated questions at
|
||||
* a later point in the conversation are still shown.
|
||||
*/
|
||||
export function uniqueById(items: Array<ResponseItem>): Array<ResponseItem> {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: Array<ResponseItem> = [];
|
||||
|
||||
for (const item of items) {
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Rule #1 – de‑duplicate by id when present
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (typeof item.id === "string" && item.id.length > 0) {
|
||||
if (seenIds.has(item.id)) {
|
||||
continue; // skip duplicates
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Rule #2 – collapse consecutive identical user messages
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (isUserMessage(item) && deduped.length > 0) {
|
||||
const prev = deduped[deduped.length - 1]!;
|
||||
|
||||
if (
|
||||
isUserMessage(prev) &&
|
||||
// Note: the `content` field is an array of message parts. Performing
|
||||
// a deep compare is over‑kill here; serialising to JSON is sufficient
|
||||
// (and fast for the tiny payloads involved).
|
||||
JSON.stringify(prev.content) === JSON.stringify(item.content)
|
||||
) {
|
||||
continue; // skip duplicate user message
|
||||
}
|
||||
}
|
||||
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { CommandConfirmation } from "../../utils/agent/agent-loop.js";
|
||||
import type { AppConfig } from "../../utils/config.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "@lib/approvals.js";
|
||||
import type { ColorName } from "chalk";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
import type { ReviewDecision } from "src/utils/agent/review.ts";
|
||||
|
||||
import TerminalChatInput from "./terminal-chat-input.js";
|
||||
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-item.js";
|
||||
import {
|
||||
calculateContextPercentRemaining,
|
||||
uniqueById,
|
||||
} from "./terminal-chat-utils.js";
|
||||
import TerminalMessageHistory from "./terminal-message-history.js";
|
||||
import { useConfirmation } from "../../hooks/use-confirmation.js";
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size.js";
|
||||
import { AgentLoop } from "../../utils/agent/agent-loop.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { getAvailableModels } from "../../utils/model-utils.js";
|
||||
import { CLI_VERSION } from "../../utils/session.js";
|
||||
import { shortCwd } from "../../utils/short-path.js";
|
||||
import { saveRollout } from "../../utils/storage/save-rollout.js";
|
||||
import ApprovalModeOverlay from "../approval-mode-overlay.js";
|
||||
import HelpOverlay from "../help-overlay.js";
|
||||
import HistoryOverlay from "../history-overlay.js";
|
||||
import ModelOverlay from "../model-overlay.js";
|
||||
import { formatCommandForDisplay } from "@lib/format-command.js";
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { inspect } from "util";
|
||||
|
||||
type Props = {
|
||||
config: AppConfig;
|
||||
prompt?: string;
|
||||
imagePaths?: Array<string>;
|
||||
approvalPolicy: ApprovalPolicy;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
|
||||
"suggest": undefined,
|
||||
"auto-edit": "greenBright",
|
||||
"full-auto": "green",
|
||||
};
|
||||
|
||||
export default function TerminalChat({
|
||||
config,
|
||||
prompt: _initialPrompt,
|
||||
imagePaths: _initialImagePaths,
|
||||
approvalPolicy: initialApprovalPolicy,
|
||||
fullStdout,
|
||||
}: Props): React.ReactElement {
|
||||
const [model, setModel] = useState<string>(config.model);
|
||||
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<Array<ResponseItem>>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// Allow switching approval modes at runtime via an overlay.
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>(
|
||||
initialApprovalPolicy,
|
||||
);
|
||||
const [thinkingSeconds, setThinkingSeconds] = useState(0);
|
||||
const { requestConfirmation, confirmationPrompt, submitConfirmation } =
|
||||
useConfirmation();
|
||||
const [overlayMode, setOverlayMode] = useState<
|
||||
"none" | "history" | "model" | "approval" | "help"
|
||||
>("none");
|
||||
|
||||
const [initialPrompt, setInitialPrompt] = useState(_initialPrompt);
|
||||
const [initialImagePaths, setInitialImagePaths] =
|
||||
useState(_initialImagePaths);
|
||||
|
||||
const PWD = React.useMemo(() => shortCwd(), []);
|
||||
|
||||
// Keep a single AgentLoop instance alive across renders;
|
||||
// recreate only when model/instructions/approvalPolicy change.
|
||||
const agentRef = React.useRef<AgentLoop>();
|
||||
const [, forceUpdate] = React.useReducer((c) => c + 1, 0); // trigger re‑render
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// DEBUG: log every render w/ key bits of state
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`render – agent? ${Boolean(agentRef.current)} loading=${loading} items=${
|
||||
items.length
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggingEnabled()) {
|
||||
log("creating NEW AgentLoop");
|
||||
log(
|
||||
`model=${model} instructions=${Boolean(
|
||||
config.instructions,
|
||||
)} approvalPolicy=${approvalPolicy}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tear down any existing loop before creating a new one
|
||||
agentRef.current?.terminate();
|
||||
|
||||
agentRef.current = new AgentLoop({
|
||||
model,
|
||||
config,
|
||||
instructions: config.instructions,
|
||||
approvalPolicy,
|
||||
onLastResponseId: setLastResponseId,
|
||||
onItem: (item) => {
|
||||
log(`onItem: ${JSON.stringify(item)}`);
|
||||
setItems((prev) => {
|
||||
const updated = uniqueById([...prev, item as ResponseItem]);
|
||||
saveRollout(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onLoading: setLoading,
|
||||
getCommandConfirmation: async (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
): Promise<CommandConfirmation> => {
|
||||
log(`getCommandConfirmation: ${command}`);
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
const { decision: review, customDenyMessage } =
|
||||
await requestConfirmation(
|
||||
<TerminalChatToolCallCommand
|
||||
commandForDisplay={commandForDisplay}
|
||||
/>,
|
||||
);
|
||||
return { review, customDenyMessage, applyPatch };
|
||||
},
|
||||
});
|
||||
|
||||
// force a render so JSX below can "see" the freshly created agent
|
||||
forceUpdate();
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(`AgentLoop created: ${inspect(agentRef.current, { depth: 1 })}`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isLoggingEnabled()) {
|
||||
log("terminating AgentLoop");
|
||||
}
|
||||
agentRef.current?.terminate();
|
||||
agentRef.current = undefined;
|
||||
forceUpdate(); // re‑render after teardown too
|
||||
};
|
||||
}, [model, config, approvalPolicy, requestConfirmation]);
|
||||
|
||||
// whenever loading starts/stops, reset or start a timer — but pause the
|
||||
// timer while a confirmation overlay is displayed so we don't trigger a
|
||||
// re‑render every second during apply_patch reviews.
|
||||
useEffect(() => {
|
||||
let handle: ReturnType<typeof setInterval> | null = null;
|
||||
// Only tick the "thinking…" timer when the agent is actually processing
|
||||
// a request *and* the user is not being asked to review a command.
|
||||
if (loading && confirmationPrompt == null) {
|
||||
setThinkingSeconds(0);
|
||||
handle = setInterval(() => {
|
||||
setThinkingSeconds((s) => s + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
}
|
||||
setThinkingSeconds(0);
|
||||
}
|
||||
return () => {
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
}
|
||||
};
|
||||
}, [loading, confirmationPrompt]);
|
||||
|
||||
// Let's also track whenever the ref becomes available
|
||||
const agent = agentRef.current;
|
||||
useEffect(() => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(`agentRef.current is now ${Boolean(agent)}`);
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Dynamic layout constraints – keep total rendered rows <= terminal rows
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { rows: terminalRows } = useTerminalSize();
|
||||
|
||||
useEffect(() => {
|
||||
const processInitialInputItems = async () => {
|
||||
if (
|
||||
(!initialPrompt || initialPrompt.trim() === "") &&
|
||||
(!initialImagePaths || initialImagePaths.length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const inputItems = [
|
||||
await createInputItem(initialPrompt || "", initialImagePaths || []),
|
||||
];
|
||||
// Clear them to prevent subsequent runs
|
||||
setInitialPrompt("");
|
||||
setInitialImagePaths([]);
|
||||
agent?.run(inputItems);
|
||||
};
|
||||
processInitialInputItems();
|
||||
}, [agent, initialPrompt, initialImagePaths]);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// In-app warning if CLI --model isn't in fetched list
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const available = await getAvailableModels();
|
||||
if (model && available.length > 0 && !available.includes(model)) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `unknown-model-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Warning: model "${model}" is not in the list of available models returned by OpenAI.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
// run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Just render every item in order, no grouping/collapse
|
||||
const lastMessageBatch = items.map((item) => ({ item }));
|
||||
const groupCounts: Record<string, number> = {};
|
||||
const userMsgCount = items.filter(
|
||||
(i) => i.type === "message" && i.role === "user",
|
||||
).length;
|
||||
|
||||
const contextLeftPercent = useMemo(
|
||||
() => calculateContextPercentRemaining(items, model),
|
||||
[items, model],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{agent ? (
|
||||
<TerminalMessageHistory
|
||||
batch={lastMessageBatch}
|
||||
groupCounts={groupCounts}
|
||||
items={items}
|
||||
userMsgCount={userMsgCount}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
loading={loading}
|
||||
thinkingSeconds={thinkingSeconds}
|
||||
fullStdout={fullStdout}
|
||||
headerProps={{
|
||||
terminalRows,
|
||||
version: CLI_VERSION,
|
||||
PWD,
|
||||
model,
|
||||
approvalPolicy,
|
||||
colorsByPolicy,
|
||||
agent,
|
||||
initialImagePaths,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Initializing agent…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{agent && (
|
||||
<TerminalChatInput
|
||||
loading={loading}
|
||||
setItems={setItems}
|
||||
isNew={Boolean(items.length === 0)}
|
||||
setLastResponseId={setLastResponseId}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
submitConfirmation={(
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) =>
|
||||
submitConfirmation({
|
||||
decision,
|
||||
customDenyMessage,
|
||||
})
|
||||
}
|
||||
contextLeftPercent={contextLeftPercent}
|
||||
openOverlay={() => setOverlayMode("history")}
|
||||
openModelOverlay={() => setOverlayMode("model")}
|
||||
openApprovalOverlay={() => setOverlayMode("approval")}
|
||||
openHelpOverlay={() => setOverlayMode("help")}
|
||||
active={overlayMode === "none"}
|
||||
interruptAgent={() => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"TerminalChat: interruptAgent invoked – calling agent.cancel()",
|
||||
);
|
||||
}
|
||||
agent.cancel();
|
||||
setLoading(false);
|
||||
}}
|
||||
submitInput={(inputs) => {
|
||||
agent.run(inputs, lastResponseId || "");
|
||||
return {};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{overlayMode === "history" && (
|
||||
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
{overlayMode === "model" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
hasLastResponse={Boolean(lastResponseId)}
|
||||
onSelect={(newModel) => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"TerminalChat: interruptAgent invoked – calling agent.cancel()",
|
||||
);
|
||||
if (!agent) {
|
||||
log("TerminalChat: agent is not ready yet");
|
||||
}
|
||||
}
|
||||
agent?.cancel();
|
||||
setLoading(false);
|
||||
|
||||
setModel(newModel);
|
||||
setLastResponseId((prev) =>
|
||||
prev && newModel !== model ? null : prev,
|
||||
);
|
||||
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `switch-model-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Switched model to ${newModel}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
setOverlayMode("none");
|
||||
}}
|
||||
onExit={() => setOverlayMode("none")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayMode === "approval" && (
|
||||
<ApprovalModeOverlay
|
||||
currentMode={approvalPolicy}
|
||||
onSelect={(newMode) => {
|
||||
agent?.cancel();
|
||||
setLoading(false);
|
||||
if (newMode === approvalPolicy) {
|
||||
return;
|
||||
}
|
||||
setApprovalPolicy(newMode as ApprovalPolicy);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `switch-approval-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Switched approval mode to ${newMode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
setOverlayMode("none");
|
||||
}}
|
||||
onExit={() => setOverlayMode("none")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayMode === "help" && (
|
||||
<HelpOverlay onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
84
codex-cli/src/components/chat/terminal-header.tsx
Normal file
84
codex-cli/src/components/chat/terminal-header.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { AgentLoop } from "../../utils/agent/agent-loop.js";
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import path from "node:path";
|
||||
import React from "react";
|
||||
|
||||
export interface TerminalHeaderProps {
|
||||
terminalRows: number;
|
||||
version: string;
|
||||
PWD: string;
|
||||
model: string;
|
||||
approvalPolicy: string;
|
||||
colorsByPolicy: Record<string, string | undefined>;
|
||||
agent?: AgentLoop;
|
||||
initialImagePaths?: Array<string>;
|
||||
}
|
||||
|
||||
const TerminalHeader: React.FC<TerminalHeaderProps> = ({
|
||||
terminalRows,
|
||||
version,
|
||||
PWD,
|
||||
model,
|
||||
approvalPolicy,
|
||||
colorsByPolicy,
|
||||
agent,
|
||||
initialImagePaths,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{terminalRows < 10 ? (
|
||||
// Compact header for small terminal windows
|
||||
<Text>
|
||||
● Codex v{version} – {PWD} – {model} –{" "}
|
||||
<Text color={colorsByPolicy[approvalPolicy]}>{approvalPolicy}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Box borderStyle="round" paddingX={1} width={64}>
|
||||
<Text>
|
||||
● OpenAI <Text bold>Codex</Text>{" "}
|
||||
<Text dimColor>
|
||||
(research preview) <Text color="blueBright">v{version}</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
width={64}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text>
|
||||
localhost <Text dimColor>session:</Text>{" "}
|
||||
<Text color="magentaBright" dimColor>
|
||||
{agent?.sessionId ?? "<no-session>"}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> workdir: <Text bold>{PWD}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> model: <Text bold>{model}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> approval:{" "}
|
||||
<Text bold color={colorsByPolicy[approvalPolicy]} dimColor>
|
||||
{approvalPolicy}
|
||||
</Text>
|
||||
</Text>
|
||||
{initialImagePaths?.map((img, idx) => (
|
||||
<Text key={img ?? idx} color="gray">
|
||||
<Text color="blueBright">↳</Text> image:{" "}
|
||||
<Text bold>{path.basename(img)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalHeader;
|
||||
76
codex-cli/src/components/chat/terminal-message-history.tsx
Normal file
76
codex-cli/src/components/chat/terminal-message-history.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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 TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
import { Box, Static, Text } from "ink";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
// A batch entry can either be a standalone response item or a grouped set of
|
||||
// items (e.g. auto‑approved tool‑call batches) that should be rendered
|
||||
// together.
|
||||
type BatchEntry = { item?: ResponseItem; group?: GroupedResponseItem };
|
||||
type MessageHistoryProps = {
|
||||
batch: Array<BatchEntry>;
|
||||
groupCounts: Record<string, number>;
|
||||
items: Array<ResponseItem>;
|
||||
userMsgCount: number;
|
||||
confirmationPrompt: React.ReactNode;
|
||||
loading: boolean;
|
||||
thinkingSeconds: number;
|
||||
headerProps: TerminalHeaderProps;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
batch,
|
||||
headerProps,
|
||||
loading,
|
||||
thinkingSeconds,
|
||||
fullStdout,
|
||||
}) => {
|
||||
const [messages, debug] = useMemo(
|
||||
() => [batch.map(({ item }) => item!), process.env["DEBUG"]],
|
||||
[batch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{loading && debug && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">{`(${thinkingSeconds}s)`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Static items={["header", ...messages]}>
|
||||
{(item, index) => {
|
||||
if (item === "header") {
|
||||
return <TerminalHeader key="header" {...headerProps} />;
|
||||
}
|
||||
|
||||
// After the guard above `item` can only be a ResponseItem.
|
||||
const message = item as ResponseItem;
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
marginLeft={
|
||||
message.type === "message" && message.role === "user" ? 0 : 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fullStdout={fullStdout}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Static>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHistory);
|
||||
81
codex-cli/src/components/chat/use-message-grouping.ts
Normal file
81
codex-cli/src/components/chat/use-message-grouping.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { parseToolCall } from "../../utils/parsers.js";
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Represents a grouped sequence of response items (e.g., function call batches).
|
||||
*/
|
||||
export type GroupedResponseItem = {
|
||||
label: string;
|
||||
items: Array<ResponseItem>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to group recent response items for display batching.
|
||||
* Returns counts of auto-approved tool call groups, the latest batch,
|
||||
* and the count of user messages in the visible window.
|
||||
*/
|
||||
export function useMessageGrouping(visibleItems: Array<ResponseItem>): {
|
||||
groupCounts: Record<string, number>;
|
||||
batch: Array<{ item?: ResponseItem; group?: GroupedResponseItem }>;
|
||||
userMsgCount: number;
|
||||
} {
|
||||
return useMemo(() => {
|
||||
// The grouping logic only depends on the subset of messages that are
|
||||
// currently rendered (visibleItems). Using that as the sole dependency
|
||||
// keeps recomputations to a minimum and avoids unnecessary work when the
|
||||
// full list of `items` changes outside of the visible window.
|
||||
let userMsgCount = 0;
|
||||
const groupCounts: Record<string, number> = {};
|
||||
visibleItems.forEach((m) => {
|
||||
if (m.type === "function_call") {
|
||||
const toolCall = parseToolCall(m);
|
||||
if (toolCall?.autoApproval) {
|
||||
const group = toolCall.autoApproval.group;
|
||||
groupCounts[group] = (groupCounts[group] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if (m.type === "message" && m.role === "user") {
|
||||
userMsgCount++;
|
||||
}
|
||||
});
|
||||
const lastFew = visibleItems.slice(-3);
|
||||
const batch: Array<{ item?: ResponseItem; group?: GroupedResponseItem }> =
|
||||
[];
|
||||
if (lastFew[0]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[0]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: lastFew,
|
||||
},
|
||||
});
|
||||
if (lastFew[2]?.type === "message") {
|
||||
batch.push({ item: lastFew[2] });
|
||||
}
|
||||
} else if (lastFew[1]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[1]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: lastFew.slice(1),
|
||||
},
|
||||
});
|
||||
} else if (lastFew[2]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[2]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: [lastFew[2]],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
lastFew.forEach((item) => batch.push({ item }));
|
||||
}
|
||||
return { groupCounts, batch, userMsgCount };
|
||||
// `items` is stable across renders while `visibleItems` changes based on
|
||||
// the scroll window. Including only `visibleItems` avoids unnecessary
|
||||
// recomputations while still producing correct results.
|
||||
}, [visibleItems]);
|
||||
}
|
||||
90
codex-cli/src/components/help-overlay.tsx
Normal file
90
codex-cli/src/components/help-overlay.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* An overlay that lists the available slash‑commands and their description.
|
||||
* The overlay is purely informational and can be dismissed with the Escape
|
||||
* key. Keeping the implementation extremely small avoids adding any new
|
||||
* dependencies or complex state handling.
|
||||
*/
|
||||
export default function HelpOverlay({
|
||||
onExit,
|
||||
}: {
|
||||
onExit: () => void;
|
||||
}): JSX.Element {
|
||||
useInput((input, key) => {
|
||||
if (key.escape || input === "q") {
|
||||
onExit();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={80}
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
<Text bold>Available commands</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" paddingX={1} paddingTop={1}>
|
||||
<Text bold dimColor>
|
||||
Slash‑commands
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/help</Text> – show this help overlay
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/model</Text> – switch the LLM model in‑session
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/approval</Text> – switch auto‑approval mode
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/history</Text> – show command & file history
|
||||
for this session
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/clear</Text> – clear screen & context
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
Keyboard shortcuts
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
<Text color="yellow">Enter</Text> – send message
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="yellow">Ctrl+J</Text> – insert newline
|
||||
</Text>
|
||||
{/* Re-enable once we re-enable new input */}
|
||||
{/*
|
||||
<Text>
|
||||
<Text color="yellow">Ctrl+X</Text>/<Text color="yellow">Ctrl+E</Text>
|
||||
– open external editor ($EDITOR)
|
||||
</Text>
|
||||
*/}
|
||||
<Text>
|
||||
<Text color="yellow">Up/Down</Text> – scroll prompt history
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="yellow">
|
||||
Esc<Text dimColor>(✕2)</Text>
|
||||
</Text>{" "}
|
||||
– interrupt current action
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="yellow">Ctrl+C</Text> – quit Codex
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>esc or q to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
237
codex-cli/src/components/history-overlay.tsx
Normal file
237
codex-cli/src/components/history-overlay.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
items: Array<ResponseItem>;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
type Mode = "commands" | "files";
|
||||
|
||||
export default function HistoryOverlay({ items, onExit }: Props): JSX.Element {
|
||||
const [mode, setMode] = useState<Mode>("commands");
|
||||
const [cursor, setCursor] = useState(0);
|
||||
|
||||
const { commands, files } = useMemo(() => buildLists(items), [items]);
|
||||
|
||||
const list = mode === "commands" ? commands : files;
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === "c") {
|
||||
setMode("commands");
|
||||
setCursor(0);
|
||||
return;
|
||||
}
|
||||
if (input === "f") {
|
||||
setMode("files");
|
||||
setCursor(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow || input === "j") {
|
||||
setCursor((c) => Math.min(list.length - 1, c + 1));
|
||||
} else if (key.upArrow || input === "k") {
|
||||
setCursor((c) => Math.max(0, c - 1));
|
||||
} else if (key.pageDown) {
|
||||
setCursor((c) => Math.min(list.length - 1, c + 10));
|
||||
} else if (key.pageUp) {
|
||||
setCursor((c) => Math.max(0, c - 10));
|
||||
} else if (input === "g") {
|
||||
setCursor(0);
|
||||
} else if (input === "G") {
|
||||
setCursor(list.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
const rows = process.stdout.rows || 24;
|
||||
const headerRows = 2;
|
||||
const footerRows = 1;
|
||||
const maxVisible = Math.max(4, rows - headerRows - footerRows);
|
||||
|
||||
const firstVisible = Math.min(
|
||||
Math.max(0, cursor - Math.floor(maxVisible / 2)),
|
||||
Math.max(0, list.length - maxVisible),
|
||||
);
|
||||
const visible = list.slice(firstVisible, firstVisible + maxVisible);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={100}
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
<Text bold>
|
||||
{mode === "commands" ? "Commands run" : "Files touched"} (
|
||||
{list.length})
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{visible.map((txt, idx) => {
|
||||
const absIdx = firstVisible + idx;
|
||||
const selected = absIdx === cursor;
|
||||
return (
|
||||
<Text key={absIdx} color={selected ? "cyan" : undefined}>
|
||||
{selected ? "› " : " "}
|
||||
{txt}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
esc Close ↑↓ Scroll PgUp/PgDn g/G First/Last c Commands f Files
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLists(items: Array<ResponseItem>): {
|
||||
commands: Array<string>;
|
||||
files: Array<string>;
|
||||
} {
|
||||
const commands: Array<string> = [];
|
||||
const filesSet = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (
|
||||
item.type === "message" &&
|
||||
(item as unknown as { role?: string }).role === "user"
|
||||
) {
|
||||
// TODO: We're ignoring images/files here.
|
||||
const parts =
|
||||
(item as unknown as { content?: Array<unknown> }).content ?? [];
|
||||
const texts: Array<string> = [];
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (part && typeof part === "object" && "text" in part) {
|
||||
const t = (part as unknown as { text?: string }).text;
|
||||
if (typeof t === "string" && t.length > 0) {
|
||||
texts.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (texts.length > 0) {
|
||||
const fullPrompt = texts.join(" ");
|
||||
// Truncate very long prompts so the history view stays legible.
|
||||
const truncated =
|
||||
fullPrompt.length > 120 ? `${fullPrompt.slice(0, 117)}…` : fullPrompt;
|
||||
commands.push(`> ${truncated}`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// We are interested in tool calls which – for the OpenAI client – are
|
||||
// represented as `function_call` response items. Skip everything else.
|
||||
if (item.type !== "function_call") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { name: toolName, arguments: argsString } = item as unknown as {
|
||||
name: unknown;
|
||||
arguments: unknown;
|
||||
};
|
||||
|
||||
if (typeof argsString !== "string") {
|
||||
// Malformed – still record the tool name to give users maximal context.
|
||||
if (typeof toolName === "string" && toolName.length > 0) {
|
||||
commands.push(toolName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Best‑effort attempt to parse the JSON arguments. We never throw on parse
|
||||
// failure – the history view must be resilient to bad data.
|
||||
let argsJson: unknown = undefined;
|
||||
try {
|
||||
argsJson = JSON.parse(argsString);
|
||||
} catch {
|
||||
argsJson = undefined;
|
||||
}
|
||||
|
||||
// 1) Shell / exec‑like tool calls expose a `cmd` or `command` property
|
||||
// that is an array of strings. These are rendered as the joined command
|
||||
// line for familiarity with traditional shells.
|
||||
const argsObj = argsJson as Record<string, unknown> | undefined;
|
||||
const cmdArray: Array<string> | undefined = Array.isArray(argsObj?.["cmd"])
|
||||
? (argsObj!["cmd"] as Array<string>)
|
||||
: Array.isArray(argsObj?.["command"])
|
||||
? (argsObj!["command"] as Array<string>)
|
||||
: undefined;
|
||||
|
||||
if (cmdArray && cmdArray.length > 0) {
|
||||
commands.push(cmdArray.join(" "));
|
||||
|
||||
// Heuristic for file paths in command args
|
||||
for (const part of cmdArray) {
|
||||
if (!part.startsWith("-") && part.includes("/")) {
|
||||
filesSet.add(part);
|
||||
}
|
||||
}
|
||||
|
||||
// Special‑case apply_patch so we can extract the list of modified files
|
||||
if (cmdArray[0] === "apply_patch" || cmdArray.includes("apply_patch")) {
|
||||
const patchTextMaybe = cmdArray.find((s) =>
|
||||
s.includes("*** Begin Patch"),
|
||||
);
|
||||
if (typeof patchTextMaybe === "string") {
|
||||
const lines = patchTextMaybe.split("\n");
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^[-+]{3} [ab]\/(.+)$/);
|
||||
if (m && m[1]) {
|
||||
filesSet.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue; // We processed this as a command; no need to treat as generic tool call.
|
||||
}
|
||||
|
||||
// 2) Non‑exec tool calls – we fall back to recording the tool name plus a
|
||||
// short argument representation to give users an idea of what
|
||||
// happened.
|
||||
if (typeof toolName === "string" && toolName.length > 0) {
|
||||
let summary = toolName;
|
||||
|
||||
if (argsJson && typeof argsJson === "object") {
|
||||
// Extract a few common argument keys to make the summary more useful
|
||||
// without being overly verbose.
|
||||
const interestingKeys = [
|
||||
"path",
|
||||
"file",
|
||||
"filepath",
|
||||
"filename",
|
||||
"pattern",
|
||||
];
|
||||
for (const key of interestingKeys) {
|
||||
const val = (argsJson as Record<string, unknown>)[key];
|
||||
if (typeof val === "string") {
|
||||
summary += ` ${val}`;
|
||||
if (val.includes("/")) {
|
||||
filesSet.add(val);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commands.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return { commands, files: Array.from(filesSet) };
|
||||
}
|
||||
108
codex-cli/src/components/model-overlay.tsx
Normal file
108
codex-cli/src/components/model-overlay.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import TypeaheadOverlay from "./typeahead-overlay.js";
|
||||
import {
|
||||
getAvailableModels,
|
||||
RECOMMENDED_MODELS,
|
||||
} from "../utils/model-utils.js";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Props for <ModelOverlay>.
|
||||
*
|
||||
* When `hasLastResponse` is true the user has already received at least one
|
||||
* assistant response in the current session which means switching models is no
|
||||
* longer supported – the overlay should therefore show an error and only allow
|
||||
* the user to close it.
|
||||
*/
|
||||
type Props = {
|
||||
currentModel: string;
|
||||
hasLastResponse: boolean;
|
||||
onSelect: (model: string) => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
export default function ModelOverlay({
|
||||
currentModel,
|
||||
hasLastResponse,
|
||||
onSelect,
|
||||
onExit,
|
||||
}: Props): JSX.Element {
|
||||
const [items, setItems] = useState<Array<{ label: string; value: string }>>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const models = await getAvailableModels();
|
||||
|
||||
// Split the list into recommended and “other” models.
|
||||
const recommended = RECOMMENDED_MODELS.filter((m) => models.includes(m));
|
||||
const others = models.filter((m) => !recommended.includes(m));
|
||||
|
||||
const ordered = [...recommended, ...others.sort()];
|
||||
|
||||
setItems(
|
||||
ordered.map((m) => ({
|
||||
label: recommended.includes(m) ? `⭐ ${m}` : m,
|
||||
value: m,
|
||||
})),
|
||||
);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// If the conversation already contains a response we cannot change the model
|
||||
// anymore because the backend requires a consistent model across the entire
|
||||
// run. In that scenario we replace the regular typeahead picker with a
|
||||
// simple message instructing the user to start a new chat. The only
|
||||
// available action is to dismiss the overlay (Esc or Enter).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Always register input handling so hooks are called consistently.
|
||||
useInput((_input, key) => {
|
||||
if (hasLastResponse && (key.escape || key.return)) {
|
||||
onExit();
|
||||
}
|
||||
});
|
||||
|
||||
if (hasLastResponse) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={80}
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
<Text bold color="red">
|
||||
Unable to switch model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>
|
||||
<Text>
|
||||
You can only pick a model before the assistant sends its first
|
||||
response. To use a different model please start a new chat.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>press esc or enter to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TypeaheadOverlay
|
||||
title="Switch model"
|
||||
description={
|
||||
<Text>
|
||||
Current model: <Text color="greenBright">{currentModel}</Text>
|
||||
</Text>
|
||||
}
|
||||
initialItems={items}
|
||||
currentValue={currentModel}
|
||||
onSelect={onSelect}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-expect-error select.js is JavaScript and has no types
|
||||
import { Select } from "../vendor/ink-select/select";
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { AutoApprovalMode } from "src/utils/auto-approval-mode";
|
||||
|
||||
// TODO: figure out why `cli-spinners` fails on Node v20.9.0
|
||||
// which is why we have to do this in the first place
|
||||
|
||||
export function OnboardingApprovalMode(): React.ReactElement {
|
||||
return (
|
||||
<Box>
|
||||
<Text>Choose what you want to have to approve:</Text>
|
||||
<Select
|
||||
onChange={() => {}}
|
||||
// onChange={(value: ReviewDecision) => onReviewCommand(value)}
|
||||
options={[
|
||||
{
|
||||
label: "Auto-approve file reads, but ask me for edits and commands",
|
||||
value: AutoApprovalMode.SUGGEST,
|
||||
},
|
||||
{
|
||||
label: "Auto-approve file reads and edits, but ask me for commands",
|
||||
value: AutoApprovalMode.AUTO_EDIT,
|
||||
},
|
||||
{
|
||||
label:
|
||||
"Auto-approve file reads, edits, and running commands network-disabled",
|
||||
value: AutoApprovalMode.FULL_AUTO,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
681
codex-cli/src/components/singlepass-cli-app.tsx
Normal file
681
codex-cli/src/components/singlepass-cli-app.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import type { AppConfig } from "../utils/config";
|
||||
import type { FileOperation } from "../utils/singlepass/file_ops";
|
||||
|
||||
import Spinner from "./vendor/ink-spinner"; // Third‑party / vendor components
|
||||
import TextInput from "./vendor/ink-text-input";
|
||||
import { OPENAI_TIMEOUT_MS, OPENAI_BASE_URL } from "../utils/config";
|
||||
import {
|
||||
generateDiffSummary,
|
||||
generateEditSummary,
|
||||
} from "../utils/singlepass/code_diff";
|
||||
import { renderTaskContext } from "../utils/singlepass/context";
|
||||
import {
|
||||
getFileContents,
|
||||
loadIgnorePatterns,
|
||||
makeAsciiDirectoryStructure,
|
||||
} from "../utils/singlepass/context_files";
|
||||
import { EditedFilesSchema } from "../utils/singlepass/file_ops";
|
||||
import * as fsSync from "fs";
|
||||
import * as fsPromises from "fs/promises";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import OpenAI from "openai";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import path from "path";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
/** Maximum number of characters allowed in the context passed to the model. */
|
||||
const MAX_CONTEXT_CHARACTER_LIMIT = 2_000_000;
|
||||
|
||||
// --- prompt history support (same as for rest of CLI) ---
|
||||
const PROMPT_HISTORY_KEY = "__codex_singlepass_prompt_history";
|
||||
function loadPromptHistory(): Array<string> {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(PROMPT_HISTORY_KEY);
|
||||
if (raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// fallback to process.env-based temp storage if localStorage isn't available
|
||||
try {
|
||||
if (process && process.env && process.env["HOME"]) {
|
||||
const p = path.join(
|
||||
process.env["HOME"],
|
||||
".codex_singlepass_history.json",
|
||||
);
|
||||
if (fsSync.existsSync(p)) {
|
||||
return JSON.parse(fsSync.readFileSync(p, "utf8"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function savePromptHistory(history: Array<string>) {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(PROMPT_HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// fallback to process.env-based temp storage if localStorage isn't available
|
||||
try {
|
||||
if (process && process.env && process.env["HOME"]) {
|
||||
const p = path.join(
|
||||
process.env["HOME"],
|
||||
".codex_singlepass_history.json",
|
||||
);
|
||||
fsSync.writeFileSync(p, JSON.stringify(history), "utf8");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small animated spinner shown while the request to OpenAI is in‑flight.
|
||||
*/
|
||||
function WorkingSpinner({ text = "Working" }: { text?: string }) {
|
||||
const [dots, setDots] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDots((d) => (d.length < 3 ? d + "." : ""));
|
||||
}, 400);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{text}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryInfo({
|
||||
rootPath,
|
||||
files,
|
||||
contextLimit,
|
||||
showStruct = false,
|
||||
}: {
|
||||
rootPath: string;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
contextLimit: number;
|
||||
showStruct?: boolean;
|
||||
}) {
|
||||
const asciiStruct = React.useMemo(
|
||||
() =>
|
||||
showStruct
|
||||
? makeAsciiDirectoryStructure(
|
||||
rootPath,
|
||||
files.map((fc) => fc.path),
|
||||
)
|
||||
: null,
|
||||
[showStruct, rootPath, files],
|
||||
);
|
||||
const totalChars = files.reduce((acc, fc) => acc + fc.content.length, 0);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={80}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text> <Text bold>Directory:</Text>{" "}
|
||||
{rootPath}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>{" "}
|
||||
<Text bold>Paths in context:</Text> {rootPath} ({files.length} files)
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text> <Text bold>Context size:</Text>{" "}
|
||||
{totalChars} / {contextLimit} ( ~
|
||||
{((totalChars / contextLimit) * 100).toFixed(2)}% )
|
||||
</Text>
|
||||
{showStruct ? (
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>
|
||||
<Text bold>Context structure:</Text>
|
||||
<Text>{asciiStruct}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>{" "}
|
||||
<Text bold>Context structure:</Text>{" "}
|
||||
<Text dimColor>
|
||||
Hidden. Type <Text color="cyan">/context</Text> to show it.
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{totalChars > contextLimit ? (
|
||||
<Text color="red">
|
||||
Files exceed context limit. See breakdown below.
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryAndDiffs({
|
||||
summary,
|
||||
diffs,
|
||||
}: {
|
||||
summary: string;
|
||||
diffs: string;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="yellow" bold>
|
||||
Summary:
|
||||
</Text>
|
||||
<Text>{summary}</Text>
|
||||
<Text color="cyan" bold>
|
||||
Proposed Diffs:
|
||||
</Text>
|
||||
<Text>{diffs}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Input prompts */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function InputPrompt({
|
||||
message,
|
||||
onSubmit,
|
||||
onCtrlC,
|
||||
}: {
|
||||
message: string;
|
||||
onSubmit: (val: string) => void;
|
||||
onCtrlC?: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history] = useState(() => loadPromptHistory());
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
const [, setShowDirInfo] = useState(false);
|
||||
|
||||
useInput((input, key) => {
|
||||
if ((key.ctrl && (input === "c" || input === "C")) || input === "\u0003") {
|
||||
// Ctrl+C pressed – treat as interrupt
|
||||
if (onCtrlC) {
|
||||
onCtrlC();
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (value.trim() !== "") {
|
||||
// Save to history (front of list)
|
||||
const updated =
|
||||
history[history.length - 1] === value ? history : [...history, value];
|
||||
savePromptHistory(updated.slice(-50));
|
||||
}
|
||||
onSubmit(value.trim());
|
||||
} else if (key.upArrow) {
|
||||
if (history.length > 0) {
|
||||
if (historyIndex == null) {
|
||||
setDraftInput(value);
|
||||
}
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(history[newIndex] ?? "");
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
if (historyIndex == null) {
|
||||
return;
|
||||
}
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setValue(draftInput);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(history[newIndex] ?? "");
|
||||
}
|
||||
} else if (input === "/context" || input === ":context") {
|
||||
setShowDirInfo(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text>{message}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder="Type here…"
|
||||
showCursor
|
||||
focus
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationPrompt({
|
||||
message,
|
||||
onResult,
|
||||
}: {
|
||||
message: string;
|
||||
onResult: (accept: boolean) => void;
|
||||
}) {
|
||||
useInput((input, key) => {
|
||||
if (key.return || input.toLowerCase() === "y") {
|
||||
onResult(true);
|
||||
} else if (input.toLowerCase() === "n" || key.escape) {
|
||||
onResult(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text>{message} [y/N] </Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuePrompt({ onResult }: { onResult: (cont: boolean) => void }) {
|
||||
useInput((input, key) => {
|
||||
if (input.toLowerCase() === "y" || key.return) {
|
||||
onResult(true);
|
||||
} else if (input.toLowerCase() === "n" || key.escape) {
|
||||
onResult(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text>Do you want to apply another edit? [y/N] </Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface SinglePassAppProps {
|
||||
originalPrompt?: string;
|
||||
config: AppConfig;
|
||||
rootPath: string;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function SinglePassApp({
|
||||
originalPrompt,
|
||||
config,
|
||||
rootPath,
|
||||
onExit,
|
||||
}: SinglePassAppProps): JSX.Element {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState<
|
||||
| "init"
|
||||
| "prompt"
|
||||
| "thinking"
|
||||
| "confirm"
|
||||
| "skipped"
|
||||
| "applied"
|
||||
| "noops"
|
||||
| "error"
|
||||
| "interrupted"
|
||||
>("init");
|
||||
|
||||
// we don't need to read the current prompt / spinner state outside of
|
||||
// updating functions, so we intentionally ignore the first tuple element.
|
||||
const [, setPrompt] = useState(originalPrompt ?? "");
|
||||
const [files, setFiles] = useState<Array<{ path: string; content: string }>>(
|
||||
[],
|
||||
);
|
||||
const [diffInfo, setDiffInfo] = useState<{
|
||||
summary: string;
|
||||
diffs: string;
|
||||
ops: Array<FileOperation>;
|
||||
}>({ summary: "", diffs: "", ops: [] });
|
||||
const [, setShowSpinner] = useState(false);
|
||||
const [applyOps, setApplyOps] = useState<Array<FileOperation>>([]);
|
||||
const [quietExit, setQuietExit] = useState(false);
|
||||
const [showDirInfo, setShowDirInfo] = useState(false);
|
||||
const contextLimit = MAX_CONTEXT_CHARACTER_LIMIT;
|
||||
const inputPromptValueRef = useRef<string>("");
|
||||
|
||||
/* ---------------------------- Load file context --------------------------- */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const ignorePats = loadIgnorePatterns();
|
||||
const fileContents = await getFileContents(rootPath, ignorePats);
|
||||
setFiles(fileContents);
|
||||
})();
|
||||
}, [rootPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length) {
|
||||
setState("prompt");
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
/* -------------------------------- Helpers -------------------------------- */
|
||||
|
||||
async function runSinglePassTask(userPrompt: string) {
|
||||
setPrompt(userPrompt);
|
||||
setShowSpinner(true);
|
||||
setState("thinking");
|
||||
|
||||
try {
|
||||
const taskContextStr = renderTaskContext({
|
||||
prompt: userPrompt,
|
||||
input_paths: [rootPath],
|
||||
input_paths_structure: "(omitted for brevity in single pass mode)",
|
||||
files,
|
||||
});
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.apiKey ?? "",
|
||||
baseURL: OPENAI_BASE_URL || undefined,
|
||||
timeout: OPENAI_TIMEOUT_MS,
|
||||
});
|
||||
const chatResp = await openai.beta.chat.completions.parse({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: taskContextStr,
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(EditedFilesSchema, "schema"),
|
||||
});
|
||||
|
||||
const edited = chatResp.choices[0]?.message?.parsed ?? null;
|
||||
|
||||
setShowSpinner(false);
|
||||
|
||||
if (!edited || !Array.isArray(edited.ops)) {
|
||||
setState("noops");
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMap: Record<string, string> = {};
|
||||
for (const fc of files) {
|
||||
originalMap[fc.path] = fc.content;
|
||||
}
|
||||
|
||||
const [combinedDiffs, opsToApply] = generateDiffSummary(
|
||||
edited,
|
||||
originalMap,
|
||||
);
|
||||
|
||||
if (!opsToApply.length) {
|
||||
setState("noops");
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = generateEditSummary(opsToApply, originalMap);
|
||||
setDiffInfo({ summary, diffs: combinedDiffs, ops: opsToApply });
|
||||
setApplyOps(opsToApply);
|
||||
setState("confirm");
|
||||
} catch (err) {
|
||||
setShowSpinner(false);
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFileOps(ops: Array<FileOperation>) {
|
||||
for (const op of ops) {
|
||||
if (op.delete) {
|
||||
try {
|
||||
await fsPromises.unlink(op.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (op.move_to) {
|
||||
const newContent = op.updated_full_content || "";
|
||||
try {
|
||||
await fsPromises.mkdir(path.dirname(op.move_to), { recursive: true });
|
||||
await fsPromises.writeFile(op.move_to, newContent, "utf-8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(op.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
const newContent = op.updated_full_content || "";
|
||||
try {
|
||||
await fsPromises.mkdir(path.dirname(op.path), { recursive: true });
|
||||
await fsPromises.writeFile(op.path, newContent, "utf-8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
setState("applied");
|
||||
}
|
||||
|
||||
/* --------------------------------- Render -------------------------------- */
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (state === "applied") {
|
||||
setState("prompt");
|
||||
} else if (
|
||||
(key.ctrl && (_input === "c" || _input === "C")) ||
|
||||
_input === "\u0003"
|
||||
) {
|
||||
// If in thinking mode, treat this as an interrupt and reset to prompt
|
||||
if (state === "thinking") {
|
||||
setState("interrupted");
|
||||
// If you want to exit the process altogether instead:
|
||||
// app.exit();
|
||||
// if (onExit) onExit();
|
||||
} else if (state === "prompt") {
|
||||
// Ctrl+C in prompt mode quits
|
||||
app.exit();
|
||||
if (onExit) {
|
||||
onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (quietExit) {
|
||||
setTimeout(() => {
|
||||
onExit && onExit();
|
||||
app.exit();
|
||||
}, 100);
|
||||
return <Text>Session complete.</Text>;
|
||||
}
|
||||
|
||||
if (state === "init") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>Directory: {rootPath}</Text>
|
||||
<Text color="gray">Loading file context…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Error calling OpenAI API.</Text>
|
||||
<ContinuePrompt
|
||||
onResult={(cont) => {
|
||||
if (!cont) {
|
||||
setQuietExit(true);
|
||||
} else {
|
||||
setState("prompt");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "noops") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">No valid operations returned.</Text>
|
||||
<ContinuePrompt
|
||||
onResult={(cont) => {
|
||||
if (!cont) {
|
||||
setQuietExit(true);
|
||||
} else {
|
||||
setState("prompt");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "applied") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="green">Changes have been applied.</Text>
|
||||
<Text color="gray">Press any key to continue…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "thinking") {
|
||||
return <WorkingSpinner />;
|
||||
}
|
||||
|
||||
if (state === "interrupted") {
|
||||
// Reset prompt input value (clears what was typed before interruption)
|
||||
inputPromptValueRef.current = "";
|
||||
setTimeout(() => setState("prompt"), 250);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">
|
||||
Interrupted. Press Enter to return to prompt mode.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "prompt") {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* Info Box */}
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1} width={80}>
|
||||
<Text>
|
||||
<Text bold color="magenta">
|
||||
OpenAI <Text bold>Codex</Text>
|
||||
</Text>{" "}
|
||||
<Text dimColor>(full context mode)</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color="greenBright">
|
||||
→
|
||||
</Text>{" "}
|
||||
<Text bold>Model:</Text> {config.model}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Directory info */}
|
||||
<DirectoryInfo
|
||||
rootPath={rootPath}
|
||||
files={files}
|
||||
contextLimit={contextLimit}
|
||||
showStruct={showDirInfo}
|
||||
/>
|
||||
|
||||
{/* Prompt Input Box */}
|
||||
<Box borderStyle="round" paddingX={1}>
|
||||
<InputPrompt
|
||||
message=">>> "
|
||||
onSubmit={(val) => {
|
||||
// Support /context as a command to show the directory structure.
|
||||
if (val === "/context" || val === ":context") {
|
||||
setShowDirInfo(true);
|
||||
setPrompt("");
|
||||
return;
|
||||
} else {
|
||||
setShowDirInfo(false);
|
||||
}
|
||||
|
||||
// Continue if prompt is empty.
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
runSinglePassTask(val);
|
||||
}}
|
||||
onCtrlC={() => {
|
||||
setState("interrupted");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{"Type /context to display the directory structure."}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" Press Ctrl+C at any time to interrupt / exit."}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "confirm") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SummaryAndDiffs summary={diffInfo.summary} diffs={diffInfo.diffs} />
|
||||
<ConfirmationPrompt
|
||||
message="Apply these changes?"
|
||||
onResult={(accept) => {
|
||||
if (accept) {
|
||||
applyFileOps(applyOps);
|
||||
} else {
|
||||
setState("skipped");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "skipped") {
|
||||
setTimeout(() => {
|
||||
setState("prompt");
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Skipped proposed changes.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color="gray">…</Text>;
|
||||
}
|
||||
|
||||
export default {};
|
||||
163
codex-cli/src/components/typeahead-overlay.tsx
Normal file
163
codex-cli/src/components/typeahead-overlay.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import TextInput from "./vendor/ink-text-input.js";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export type TypeaheadItem = { label: string; value: string };
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
initialItems: Array<TypeaheadItem>;
|
||||
currentValue?: string;
|
||||
limit?: number;
|
||||
onSelect: (value: string) => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic overlay that combines a TextInput with a filtered SelectInput.
|
||||
* It is intentionally dependency‑free so it can be re‑used by multiple
|
||||
* overlays (model picker, command picker, …).
|
||||
*/
|
||||
export default function TypeaheadOverlay({
|
||||
title,
|
||||
description,
|
||||
initialItems,
|
||||
currentValue,
|
||||
limit = 10,
|
||||
onSelect,
|
||||
onExit,
|
||||
}: Props): JSX.Element {
|
||||
const [value, setValue] = useState("");
|
||||
const [items, setItems] = useState<Array<TypeaheadItem>>(initialItems);
|
||||
|
||||
// Keep internal items list in sync when the caller provides new options
|
||||
// (e.g. ModelOverlay fetches models asynchronously).
|
||||
React.useEffect(() => {
|
||||
setItems(initialItems);
|
||||
}, [initialItems]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exit on ESC */
|
||||
/* ------------------------------------------------------------------ */
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) {
|
||||
onExit();
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Filtering & Ranking */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const q = value.toLowerCase();
|
||||
const filtered =
|
||||
q.length === 0
|
||||
? items
|
||||
: items.filter((i) => i.label.toLowerCase().includes(q));
|
||||
|
||||
/*
|
||||
* Sort logic:
|
||||
* 1. Keep the currently‑selected value at the very top so switching back
|
||||
* to it is always a single <enter> press away.
|
||||
* 2. When the user has not typed anything yet (q === ""), keep the
|
||||
* original order provided by `initialItems`. This allows callers to
|
||||
* surface a hand‑picked list of recommended / frequently‑used options
|
||||
* at the top while still falling back to a deterministic alphabetical
|
||||
* order for the rest of the list (they can simply pre‑sort the array
|
||||
* before passing it in).
|
||||
* 3. As soon as the user starts typing we revert to the previous ranking
|
||||
* mechanism that tries to put the best match first and then sorts the
|
||||
* remainder alphabetically.
|
||||
*/
|
||||
|
||||
const ranked = filtered.sort((a, b) => {
|
||||
if (a.value === currentValue) {
|
||||
return -1;
|
||||
}
|
||||
if (b.value === currentValue) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Preserve original order when no query is present so we keep any caller
|
||||
// defined prioritisation (e.g. recommended models).
|
||||
if (q.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const ia = a.label.toLowerCase().indexOf(q);
|
||||
const ib = b.label.toLowerCase().indexOf(q);
|
||||
if (ia !== ib) {
|
||||
return ia - ib;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const selectItems = ranked;
|
||||
|
||||
if (
|
||||
process.env["DEBUG_TYPEAHEAD"] === "1" ||
|
||||
process.env["DEBUG_TYPEAHEAD"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"[TypeaheadOverlay] value=",
|
||||
value,
|
||||
"items=",
|
||||
items.length,
|
||||
"visible=",
|
||||
selectItems.map((i) => i.label),
|
||||
);
|
||||
}
|
||||
const initialIndex = selectItems.findIndex((i) => i.value === currentValue);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={80}
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
<Text bold>{title}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" paddingX={1} gap={1}>
|
||||
{description}
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(submitted) => {
|
||||
// Prefer the first visible item; otherwise fall back to whatever
|
||||
// the user typed so they can switch to a model that wasn't in the
|
||||
// pre‑fetched list.
|
||||
const target = selectItems[0]?.value ?? submitted.trim();
|
||||
if (target) {
|
||||
onSelect(target);
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{selectItems.length > 0 && (
|
||||
<SelectInput
|
||||
limit={limit}
|
||||
items={selectItems}
|
||||
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
||||
isFocused
|
||||
onSelect={(item: TypeaheadItem) => {
|
||||
if (item.value) {
|
||||
onSelect(item.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box paddingX={1}>
|
||||
{/* Slightly more verbose footer to make the search behaviour crystal‑clear */}
|
||||
<Text dimColor>type to search · enter to confirm · esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1293
codex-cli/src/components/vendor/cli-spinners/index.js
vendored
Normal file
1293
codex-cli/src/components/vendor/cli-spinners/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
codex-cli/src/components/vendor/ink-select/index.js
vendored
Normal file
1
codex-cli/src/components/vendor/ink-select/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./select.js";
|
||||
26
codex-cli/src/components/vendor/ink-select/option-map.js
vendored
Normal file
26
codex-cli/src/components/vendor/ink-select/option-map.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export default class OptionMap extends Map {
|
||||
first;
|
||||
constructor(options) {
|
||||
const items = [];
|
||||
let firstItem;
|
||||
let previous;
|
||||
let index = 0;
|
||||
for (const option of options) {
|
||||
const item = {
|
||||
...option,
|
||||
previous,
|
||||
next: undefined,
|
||||
index,
|
||||
};
|
||||
if (previous) {
|
||||
previous.next = item;
|
||||
}
|
||||
firstItem ||= item;
|
||||
items.push([option.value, item]);
|
||||
index++;
|
||||
previous = item;
|
||||
}
|
||||
super(items);
|
||||
this.first = firstItem;
|
||||
}
|
||||
}
|
||||
27
codex-cli/src/components/vendor/ink-select/select-option.js
vendored
Normal file
27
codex-cli/src/components/vendor/ink-select/select-option.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import figures from "figures";
|
||||
import { styles } from "./theme";
|
||||
export function SelectOption({ isFocused, isSelected, children }) {
|
||||
return React.createElement(
|
||||
Box,
|
||||
{ ...styles.option({ isFocused }) },
|
||||
isFocused &&
|
||||
React.createElement(
|
||||
Text,
|
||||
{ ...styles.focusIndicator() },
|
||||
figures.pointer,
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ ...styles.label({ isFocused, isSelected }) },
|
||||
children,
|
||||
),
|
||||
isSelected &&
|
||||
React.createElement(
|
||||
Text,
|
||||
{ ...styles.selectedIndicator() },
|
||||
figures.tick,
|
||||
),
|
||||
);
|
||||
}
|
||||
53
codex-cli/src/components/vendor/ink-select/select.js
vendored
Normal file
53
codex-cli/src/components/vendor/ink-select/select.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { styles } from "./theme";
|
||||
import { SelectOption } from "./select-option";
|
||||
import { useSelectState } from "./use-select-state";
|
||||
import { useSelect } from "./use-select";
|
||||
export function Select({
|
||||
isDisabled = false,
|
||||
visibleOptionCount = 5,
|
||||
highlightText,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) {
|
||||
const state = useSelectState({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
useSelect({ isDisabled, state });
|
||||
return React.createElement(
|
||||
Box,
|
||||
{ ...styles.container() },
|
||||
state.visibleOptions.map((option) => {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
let label = option.label;
|
||||
if (highlightText && option.label.includes(highlightText)) {
|
||||
const index = option.label.indexOf(highlightText);
|
||||
label = React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
option.label.slice(0, index),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ ...styles.highlightedText() },
|
||||
highlightText,
|
||||
),
|
||||
option.label.slice(index + highlightText.length),
|
||||
);
|
||||
}
|
||||
return React.createElement(
|
||||
SelectOption,
|
||||
{
|
||||
key: option.value,
|
||||
isFocused: !isDisabled && state.focusedValue === option.value,
|
||||
isSelected: state.value === option.value,
|
||||
},
|
||||
label,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
32
codex-cli/src/components/vendor/ink-select/theme.js
vendored
Normal file
32
codex-cli/src/components/vendor/ink-select/theme.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
const theme = {
|
||||
styles: {
|
||||
container: () => ({
|
||||
flexDirection: "column",
|
||||
}),
|
||||
option: ({ isFocused }) => ({
|
||||
gap: 1,
|
||||
paddingLeft: isFocused ? 0 : 2,
|
||||
}),
|
||||
selectedIndicator: () => ({
|
||||
color: "green",
|
||||
}),
|
||||
focusIndicator: () => ({
|
||||
color: "blue",
|
||||
}),
|
||||
label({ isFocused, isSelected }) {
|
||||
let color;
|
||||
if (isSelected) {
|
||||
color = "green";
|
||||
}
|
||||
if (isFocused) {
|
||||
color = "blue";
|
||||
}
|
||||
return { color };
|
||||
},
|
||||
highlightedText: () => ({
|
||||
bold: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
export const styles = theme.styles;
|
||||
export default theme;
|
||||
158
codex-cli/src/components/vendor/ink-select/use-select-state.js
vendored
Normal file
158
codex-cli/src/components/vendor/ink-select/use-select-state.js
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { useReducer, useCallback, useMemo, useState, useEffect } from "react";
|
||||
import OptionMap from "./option-map";
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "focus-next-option": {
|
||||
if (!state.focusedValue) {
|
||||
return state;
|
||||
}
|
||||
const item = state.optionMap.get(state.focusedValue);
|
||||
if (!item) {
|
||||
return state;
|
||||
}
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
const next = item.next;
|
||||
if (!next) {
|
||||
return state;
|
||||
}
|
||||
const needsToScroll = next.index >= state.visibleToIndex;
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
};
|
||||
}
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
state.visibleToIndex + 1,
|
||||
);
|
||||
const nextVisibleFromIndex =
|
||||
nextVisibleToIndex - state.visibleOptionCount;
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
};
|
||||
}
|
||||
case "focus-previous-option": {
|
||||
if (!state.focusedValue) {
|
||||
return state;
|
||||
}
|
||||
const item = state.optionMap.get(state.focusedValue);
|
||||
if (!item) {
|
||||
return state;
|
||||
}
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
const previous = item.previous;
|
||||
if (!previous) {
|
||||
return state;
|
||||
}
|
||||
const needsToScroll = previous.index <= state.visibleFromIndex;
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
};
|
||||
}
|
||||
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1);
|
||||
const nextVisibleToIndex =
|
||||
nextVisibleFromIndex + state.visibleOptionCount;
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
};
|
||||
}
|
||||
case "select-focused-option": {
|
||||
return {
|
||||
...state,
|
||||
previousValue: state.value,
|
||||
value: state.focusedValue,
|
||||
};
|
||||
}
|
||||
case "reset": {
|
||||
return action.state;
|
||||
}
|
||||
}
|
||||
};
|
||||
const createDefaultState = ({
|
||||
visibleOptionCount: customVisibleOptionCount,
|
||||
defaultValue,
|
||||
options,
|
||||
}) => {
|
||||
const visibleOptionCount =
|
||||
typeof customVisibleOptionCount === "number"
|
||||
? Math.min(customVisibleOptionCount, options.length)
|
||||
: options.length;
|
||||
const optionMap = new OptionMap(options);
|
||||
return {
|
||||
optionMap,
|
||||
visibleOptionCount,
|
||||
focusedValue: optionMap.first?.value,
|
||||
visibleFromIndex: 0,
|
||||
visibleToIndex: visibleOptionCount,
|
||||
previousValue: defaultValue,
|
||||
value: defaultValue,
|
||||
};
|
||||
};
|
||||
export const useSelectState = ({
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ visibleOptionCount, defaultValue, options },
|
||||
createDefaultState,
|
||||
);
|
||||
const [lastOptions, setLastOptions] = useState(options);
|
||||
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
|
||||
dispatch({
|
||||
type: "reset",
|
||||
state: createDefaultState({ visibleOptionCount, defaultValue, options }),
|
||||
});
|
||||
setLastOptions(options);
|
||||
}
|
||||
const focusNextOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: "focus-next-option",
|
||||
});
|
||||
}, []);
|
||||
const focusPreviousOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: "focus-previous-option",
|
||||
});
|
||||
}, []);
|
||||
const selectFocusedOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: "select-focused-option",
|
||||
});
|
||||
}, []);
|
||||
const visibleOptions = useMemo(() => {
|
||||
return options
|
||||
.map((option, index) => ({
|
||||
...option,
|
||||
index,
|
||||
}))
|
||||
.slice(state.visibleFromIndex, state.visibleToIndex);
|
||||
}, [options, state.visibleFromIndex, state.visibleToIndex]);
|
||||
useEffect(() => {
|
||||
if (state.value && state.previousValue !== state.value) {
|
||||
onChange?.(state.value);
|
||||
}
|
||||
}, [state.previousValue, state.value, options, onChange]);
|
||||
return {
|
||||
focusedValue: state.focusedValue,
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
value: state.value,
|
||||
visibleOptions,
|
||||
focusNextOption,
|
||||
focusPreviousOption,
|
||||
selectFocusedOption,
|
||||
};
|
||||
};
|
||||
17
codex-cli/src/components/vendor/ink-select/use-select.js
vendored
Normal file
17
codex-cli/src/components/vendor/ink-select/use-select.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useInput } from "ink";
|
||||
export const useSelect = ({ isDisabled = false, state }) => {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.downArrow) {
|
||||
state.focusNextOption();
|
||||
}
|
||||
if (key.upArrow) {
|
||||
state.focusPreviousOption();
|
||||
}
|
||||
if (key.return) {
|
||||
state.selectFocusedOption();
|
||||
}
|
||||
},
|
||||
{ isActive: !isDisabled },
|
||||
);
|
||||
};
|
||||
36
codex-cli/src/components/vendor/ink-spinner.tsx
vendored
Normal file
36
codex-cli/src/components/vendor/ink-spinner.tsx
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const spinnerTypes: Record<string, string[]> = {
|
||||
dots: ["⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"],
|
||||
ball: [
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"( ●)",
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"( ● )",
|
||||
"(● )",
|
||||
],
|
||||
};
|
||||
|
||||
export default function Spinner({
|
||||
type = "dots",
|
||||
}: {
|
||||
type?: string;
|
||||
}): JSX.Element {
|
||||
const frames = spinnerTypes[type || "dots"] || [];
|
||||
const interval = 80;
|
||||
const [frame, setFrame] = useState(0);
|
||||
useInterval(() => {
|
||||
setFrame((previousFrame) => {
|
||||
const isLastFrame = previousFrame === frames.length - 1;
|
||||
return isLastFrame ? 0 : previousFrame + 1;
|
||||
});
|
||||
}, interval);
|
||||
return <Text>{frames[frame]}</Text>;
|
||||
}
|
||||
338
codex-cli/src/components/vendor/ink-text-input.tsx
vendored
Normal file
338
codex-cli/src/components/vendor/ink-text-input.tsx
vendored
Normal file
@@ -0,0 +1,338 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text, useInput } from "ink";
|
||||
import chalk from "chalk";
|
||||
import type { Except } from "type-fest";
|
||||
|
||||
export type TextInputProps = {
|
||||
/**
|
||||
* Text to display when `value` is empty.
|
||||
*/
|
||||
readonly placeholder?: string;
|
||||
|
||||
/**
|
||||
* Listen to user's input. Useful in case there are multiple input components
|
||||
* at the same time and input must be "routed" to a specific component.
|
||||
*/
|
||||
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Replace all chars and mask the value. Useful for password inputs.
|
||||
*/
|
||||
readonly mask?: string;
|
||||
|
||||
/**
|
||||
* Whether to show cursor and allow navigation inside text input with arrow keys.
|
||||
*/
|
||||
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Highlight pasted text
|
||||
*/
|
||||
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Value to display in a text input.
|
||||
*/
|
||||
readonly value: string;
|
||||
|
||||
/**
|
||||
* Function to call when value updates.
|
||||
*/
|
||||
readonly onChange: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Function to call when `Enter` is pressed, where first argument is a value of the input.
|
||||
*/
|
||||
readonly onSubmit?: (value: string) => void;
|
||||
};
|
||||
|
||||
function findPrevWordJump(prompt: string, cursorOffset: number) {
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
let lastMatch = 0;
|
||||
let currentMatch: RegExpExecArray | null;
|
||||
|
||||
const stringToCursorOffset = prompt
|
||||
.slice(0, cursorOffset)
|
||||
.replace(/[\s,.;!?]+$/, "");
|
||||
|
||||
// Loop through all matches
|
||||
while ((currentMatch = regex.exec(stringToCursorOffset)) !== null) {
|
||||
lastMatch = currentMatch.index;
|
||||
}
|
||||
|
||||
// Include the last match unless it is the first character
|
||||
if (lastMatch != 0) {
|
||||
lastMatch += 1;
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
function findNextWordJump(prompt: string, cursorOffset: number) {
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
let currentMatch: RegExpExecArray | null;
|
||||
|
||||
// Loop through all matches
|
||||
while ((currentMatch = regex.exec(prompt)) !== null) {
|
||||
if (currentMatch.index > cursorOffset) {
|
||||
return currentMatch.index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return prompt.length;
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value: originalValue,
|
||||
placeholder = "",
|
||||
focus = true,
|
||||
mask,
|
||||
highlightPastedText = false,
|
||||
showCursor = true,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: TextInputProps) {
|
||||
const [state, setState] = useState({
|
||||
cursorOffset: (originalValue || "").length,
|
||||
cursorWidth: 0,
|
||||
});
|
||||
|
||||
const { cursorOffset, cursorWidth } = state;
|
||||
|
||||
useEffect(() => {
|
||||
setState((previousState) => {
|
||||
if (!focus || !showCursor) {
|
||||
return previousState;
|
||||
}
|
||||
|
||||
const newValue = originalValue || "";
|
||||
|
||||
if (previousState.cursorOffset > newValue.length - 1) {
|
||||
return {
|
||||
cursorOffset: newValue.length,
|
||||
cursorWidth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return previousState;
|
||||
});
|
||||
}, [originalValue, focus, showCursor]);
|
||||
|
||||
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
||||
|
||||
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
||||
let renderedValue = value;
|
||||
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
||||
|
||||
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes.
|
||||
if (showCursor && focus) {
|
||||
renderedPlaceholder =
|
||||
placeholder.length > 0
|
||||
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
||||
: chalk.inverse(" ");
|
||||
|
||||
renderedValue = value.length > 0 ? "" : chalk.inverse(" ");
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (const char of value) {
|
||||
renderedValue +=
|
||||
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
|
||||
? chalk.inverse(char)
|
||||
: char;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (value.length > 0 && cursorOffset === value.length) {
|
||||
renderedValue += chalk.inverse(" ");
|
||||
}
|
||||
}
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (
|
||||
key.upArrow ||
|
||||
key.downArrow ||
|
||||
(key.ctrl && input === "c") ||
|
||||
key.tab ||
|
||||
(key.shift && key.tab)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCursorOffset = cursorOffset;
|
||||
let nextValue = originalValue;
|
||||
let nextCursorWidth = 0;
|
||||
|
||||
// TODO: continue improving the cursor management to feel native
|
||||
if (key.return) {
|
||||
if (key.meta) {
|
||||
// This does not work yet. We would like to have this behavior:
|
||||
// Mac terminal: Settings → Profiles → Keyboard → Use Option as Meta key
|
||||
// iTerm2: Open Settings → Profiles → Keys → General → Set Left/Right Option as Esc+
|
||||
// And then when Option+ENTER is pressed, we want to insert a newline.
|
||||
// However, even with the settings, the input="\n" and only key.shift is True.
|
||||
// This is likely an artifact of how ink works.
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset) +
|
||||
"\n" +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
nextCursorOffset++;
|
||||
} else {
|
||||
// Handle Enter key: support bash-style line continuation with backslash
|
||||
// -- count consecutive backslashes immediately before cursor
|
||||
// -- only a single trailing backslash at end indicates line continuation
|
||||
const isAtEnd = cursorOffset === originalValue.length;
|
||||
const trailingMatch = originalValue.match(/\\+$/);
|
||||
const trailingCount = trailingMatch ? trailingMatch[0].length : 0;
|
||||
if (isAtEnd && trailingCount === 1) {
|
||||
nextValue += "\n";
|
||||
nextCursorOffset = nextValue.length;
|
||||
nextCursorWidth = 0;
|
||||
} else if (onSubmit) {
|
||||
onSubmit(originalValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if ((key.ctrl && input === "a") || (key.meta && key.leftArrow)) {
|
||||
nextCursorOffset = 0;
|
||||
} else if ((key.ctrl && input === "e") || (key.meta && key.rightArrow)) {
|
||||
// Move cursor to end of line
|
||||
nextCursorOffset = originalValue.length;
|
||||
// Emacs/readline-style navigation and editing shortcuts
|
||||
} else if (key.ctrl && input === "b") {
|
||||
// Move cursor backward by one
|
||||
if (showCursor) {
|
||||
nextCursorOffset = Math.max(cursorOffset - 1, 0);
|
||||
}
|
||||
} else if (key.ctrl && input === "f") {
|
||||
// Move cursor forward by one
|
||||
if (showCursor) {
|
||||
nextCursorOffset = Math.min(cursorOffset + 1, originalValue.length);
|
||||
}
|
||||
} else if (key.ctrl && input === "d") {
|
||||
// Delete character at cursor (forward delete)
|
||||
if (cursorOffset < originalValue.length) {
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset) +
|
||||
originalValue.slice(cursorOffset + 1);
|
||||
}
|
||||
} else if (key.ctrl && input === "k") {
|
||||
// Kill text from cursor to end of line
|
||||
nextValue = originalValue.slice(0, cursorOffset);
|
||||
} else if (key.ctrl && input === "u") {
|
||||
// Kill text from start to cursor
|
||||
nextValue = originalValue.slice(cursorOffset);
|
||||
nextCursorOffset = 0;
|
||||
} else if (key.ctrl && input === "w") {
|
||||
// Delete the word before cursor
|
||||
{
|
||||
const left = originalValue.slice(0, cursorOffset);
|
||||
const match = left.match(/\s*\S+$/);
|
||||
const cut = match ? match[0].length : cursorOffset;
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset - cut) +
|
||||
originalValue.slice(cursorOffset);
|
||||
nextCursorOffset = cursorOffset - cut;
|
||||
}
|
||||
} else if (key.meta && (key.backspace || key.delete)) {
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
let lastMatch = 0;
|
||||
let currentMatch: RegExpExecArray | null;
|
||||
|
||||
const stringToCursorOffset = originalValue
|
||||
.slice(0, cursorOffset)
|
||||
.replace(/[\s,.;!?]+$/, "");
|
||||
|
||||
// Loop through all matches
|
||||
while ((currentMatch = regex.exec(stringToCursorOffset)) !== null) {
|
||||
lastMatch = currentMatch.index;
|
||||
}
|
||||
|
||||
// Include the last match unless it is the first character
|
||||
if (lastMatch != 0) {
|
||||
lastMatch += 1;
|
||||
}
|
||||
|
||||
nextValue =
|
||||
stringToCursorOffset.slice(0, lastMatch) +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
nextCursorOffset = lastMatch;
|
||||
} else if (key.meta && (input === "b" || key.leftArrow)) {
|
||||
nextCursorOffset = findPrevWordJump(originalValue, cursorOffset);
|
||||
} else if (key.meta && (input === "f" || key.rightArrow)) {
|
||||
nextCursorOffset = findNextWordJump(originalValue, cursorOffset);
|
||||
} else if (key.leftArrow) {
|
||||
if (showCursor) {
|
||||
nextCursorOffset--;
|
||||
}
|
||||
} else if (key.rightArrow) {
|
||||
if (showCursor) {
|
||||
nextCursorOffset++;
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
if (cursorOffset > 0) {
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset - 1) +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
|
||||
nextCursorOffset--;
|
||||
}
|
||||
} else {
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset) +
|
||||
input +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
|
||||
nextCursorOffset += input.length;
|
||||
|
||||
if (input.length > 1) {
|
||||
nextCursorWidth = input.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorOffset < 0) {
|
||||
nextCursorOffset = 0;
|
||||
}
|
||||
|
||||
if (cursorOffset > originalValue.length) {
|
||||
nextCursorOffset = originalValue.length;
|
||||
}
|
||||
|
||||
setState({
|
||||
cursorOffset: nextCursorOffset,
|
||||
cursorWidth: nextCursorWidth,
|
||||
});
|
||||
|
||||
if (nextValue !== originalValue) {
|
||||
onChange(nextValue);
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{placeholder
|
||||
? value.length > 0
|
||||
? renderedValue
|
||||
: renderedPlaceholder
|
||||
: renderedValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextInput;
|
||||
|
||||
type UncontrolledProps = {
|
||||
readonly initialValue?: string;
|
||||
} & Except<TextInputProps, "value" | "onChange">;
|
||||
|
||||
export function UncontrolledTextInput({
|
||||
initialValue = "",
|
||||
...props
|
||||
}: UncontrolledProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return <TextInput {...props} value={value} onChange={setValue} />;
|
||||
}
|
||||
62
codex-cli/src/hooks/use-confirmation.ts
Normal file
62
codex-cli/src/hooks/use-confirmation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// use-confirmation.ts
|
||||
import type { ReviewDecision } from "../utils/agent/review";
|
||||
import type React from "react";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
type ConfirmationResult = {
|
||||
decision: ReviewDecision;
|
||||
customDenyMessage?: string;
|
||||
};
|
||||
|
||||
type ConfirmationItem = {
|
||||
prompt: React.ReactNode;
|
||||
resolve: (result: ConfirmationResult) => void;
|
||||
};
|
||||
|
||||
export function useConfirmation(): {
|
||||
submitConfirmation: (result: ConfirmationResult) => void;
|
||||
requestConfirmation: (prompt: React.ReactNode) => Promise<ConfirmationResult>;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
} {
|
||||
// The current prompt is just the head of the queue
|
||||
const [current, setCurrent] = useState<ConfirmationItem | null>(null);
|
||||
// The entire queue is stored in a ref to avoid re-renders
|
||||
const queueRef = useRef<Array<ConfirmationItem>>([]);
|
||||
|
||||
// Move queue forward to the next prompt
|
||||
const advanceQueue = useCallback(() => {
|
||||
const next = queueRef.current.shift() ?? null;
|
||||
setCurrent(next);
|
||||
}, []);
|
||||
|
||||
// Called whenever someone wants a confirmation
|
||||
const requestConfirmation = useCallback(
|
||||
(prompt: React.ReactNode) => {
|
||||
return new Promise<ConfirmationResult>((resolve) => {
|
||||
const wasEmpty = queueRef.current.length === 0;
|
||||
queueRef.current.push({ prompt, resolve });
|
||||
|
||||
// If the queue was empty, we need to kick off the first prompt
|
||||
if (wasEmpty) {
|
||||
advanceQueue();
|
||||
}
|
||||
});
|
||||
},
|
||||
[advanceQueue],
|
||||
);
|
||||
|
||||
// Called whenever user picks Yes / No
|
||||
const submitConfirmation = (result: ConfirmationResult) => {
|
||||
if (current) {
|
||||
current.resolve(result);
|
||||
advanceQueue();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
confirmationPrompt: current?.prompt, // the prompt to render now
|
||||
requestConfirmation,
|
||||
submitConfirmation,
|
||||
};
|
||||
}
|
||||
26
codex-cli/src/hooks/use-terminal-size.ts
Normal file
26
codex-cli/src/hooks/use-terminal-size.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const TERMINAL_PADDING_X = 8;
|
||||
|
||||
export function useTerminalSize(): { columns: number; rows: number } {
|
||||
const [size, setSize] = useState({
|
||||
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
|
||||
rows: process.stdout.rows || 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
setSize({
|
||||
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
|
||||
rows: process.stdout.rows || 20,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.on("resize", updateSize);
|
||||
return () => {
|
||||
process.stdout.off("resize", updateSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
92
codex-cli/src/lib/approvals.test.ts
Normal file
92
codex-cli/src/lib/approvals.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { SafetyAssessment } from "./approvals";
|
||||
|
||||
import { canAutoApprove } from "./approvals";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("canAutoApprove()", () => {
|
||||
const env = {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
HOME: "/home/user",
|
||||
};
|
||||
|
||||
const writeablePaths: Array<string> = [];
|
||||
const check = (command: ReadonlyArray<string>): SafetyAssessment =>
|
||||
canAutoApprove(command, "suggest", writeablePaths, env);
|
||||
|
||||
test("simple safe commands", () => {
|
||||
expect(check(["ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["cat", "file.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Print working directory",
|
||||
group: "Navigating",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("simple safe commands within a `bash -lc` call", () => {
|
||||
expect(check(["bash", "-lc", "ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "ls $HOME"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "git show ab9811cb90"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Git show",
|
||||
group: "Using git",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("bash -lc commands with unsafe redirects", () => {
|
||||
expect(check(["bash", "-lc", "echo hello > file.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
// In theory, we could make our checker more sophisticated to auto-approve
|
||||
// This previously required approval, but now that we consider safe
|
||||
// operators like "&&" the entire expression can be auto‑approved.
|
||||
expect(check(["bash", "-lc", "ls && pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("true command is considered safe", () => {
|
||||
expect(check(["true"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "No‑op (true)",
|
||||
group: "Utility",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("commands that should require approval", () => {
|
||||
// Should this be on the auto-approved list?
|
||||
expect(check(["printenv"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["git", "commit"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["pytest"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
|
||||
});
|
||||
});
|
||||
542
codex-cli/src/lib/approvals.ts
Normal file
542
codex-cli/src/lib/approvals.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type { ParseEntry, ControlOperator } from "shell-quote";
|
||||
|
||||
import {
|
||||
identify_files_added,
|
||||
identify_files_needed,
|
||||
} from "../utils/agent/apply-patch";
|
||||
import * as path from "path";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
export type SafetyAssessment = {
|
||||
/**
|
||||
* If set, this approval is for an apply_patch call and these are the
|
||||
* arguments.
|
||||
*/
|
||||
applyPatch?: ApplyPatchCommand;
|
||||
} & (
|
||||
| {
|
||||
type: "auto-approve";
|
||||
/**
|
||||
* This must be true if the command is not on the "known safe" list, but
|
||||
* was auto-approved due to `full-auto` mode.
|
||||
*/
|
||||
runInSandbox: boolean;
|
||||
reason: string;
|
||||
group: string;
|
||||
}
|
||||
| {
|
||||
type: "ask-user";
|
||||
}
|
||||
/**
|
||||
* Reserved for a case where we are certain the command is unsafe and should
|
||||
* not be presented as an option to the user.
|
||||
*/
|
||||
| {
|
||||
type: "reject";
|
||||
reason: string;
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: This should also contain the paths that will be affected.
|
||||
export type ApplyPatchCommand = {
|
||||
patch: string;
|
||||
};
|
||||
|
||||
export type ApprovalPolicy =
|
||||
/**
|
||||
* Under this policy, only "known safe" commands as defined by
|
||||
* `isSafeCommand()` that only read files will be auto-approved.
|
||||
*/
|
||||
| "suggest"
|
||||
|
||||
/**
|
||||
* In addition to commands that are auto-approved according to the rules for
|
||||
* "suggest", commands that write files within the user's approved list of
|
||||
* writable paths will also be auto-approved.
|
||||
*/
|
||||
| "auto-edit"
|
||||
|
||||
/**
|
||||
* All commands are auto-approved, but are expected to be run in a sandbox
|
||||
* where network access is disabled and writes are limited to a specific set
|
||||
* of paths.
|
||||
*/
|
||||
| "full-auto";
|
||||
|
||||
/**
|
||||
* Tries to assess whether a command is safe to run, though may defer to the
|
||||
* user for approval.
|
||||
*
|
||||
* Note `env` must be the same `env` that will be used to spawn the process.
|
||||
*/
|
||||
export function canAutoApprove(
|
||||
command: ReadonlyArray<string>,
|
||||
policy: ApprovalPolicy,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SafetyAssessment {
|
||||
try {
|
||||
if (command[0] === "apply_patch") {
|
||||
return command.length === 2 && typeof command[1] === "string"
|
||||
? canAutoApproveApplyPatch(command[1], writableRoots, policy)
|
||||
: {
|
||||
type: "reject",
|
||||
reason: "Invalid apply_patch command",
|
||||
};
|
||||
}
|
||||
|
||||
const isSafe = isSafeCommand(command);
|
||||
if (isSafe != null) {
|
||||
const { reason, group } = isSafe;
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason,
|
||||
group,
|
||||
runInSandbox: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
command[0] === "bash" &&
|
||||
command[1] === "-lc" &&
|
||||
typeof command[2] === "string" &&
|
||||
command.length === 3
|
||||
) {
|
||||
const applyPatchArg = tryParseApplyPatch(command[2]);
|
||||
if (applyPatchArg != null) {
|
||||
return canAutoApproveApplyPatch(applyPatchArg, writableRoots, policy);
|
||||
}
|
||||
|
||||
const bashCmd = parse(command[2], env);
|
||||
|
||||
// bashCmd could be a mix of strings and operators, e.g.:
|
||||
// "ls || (true && pwd)" => [ 'ls', { op: '||' }, '(', 'true', { op: '&&' }, 'pwd', ')' ]
|
||||
// We try to ensure that *every* command segment is deemed safe and that
|
||||
// all operators belong to an allow‑list. If so, the entire expression is
|
||||
// considered auto‑approvable.
|
||||
|
||||
const shellSafe = isEntireShellExpressionSafe(bashCmd);
|
||||
if (shellSafe != null) {
|
||||
const { reason, group } = shellSafe;
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason,
|
||||
group,
|
||||
runInSandbox: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return policy === "full-auto"
|
||||
? {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Running commands",
|
||||
runInSandbox: true,
|
||||
}
|
||||
: { type: "ask-user" };
|
||||
} catch (err) {
|
||||
if (policy === "full-auto") {
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Running commands",
|
||||
runInSandbox: true,
|
||||
};
|
||||
} else {
|
||||
return { type: "ask-user" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canAutoApproveApplyPatch(
|
||||
applyPatchArg: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
policy: ApprovalPolicy,
|
||||
): SafetyAssessment {
|
||||
switch (policy) {
|
||||
case "full-auto":
|
||||
// Continue to see if this can be auto-approved.
|
||||
break;
|
||||
case "suggest":
|
||||
return {
|
||||
type: "ask-user",
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
case "auto-edit":
|
||||
// Continue to see if this can be auto-approved.
|
||||
break;
|
||||
}
|
||||
|
||||
if (isWritePatchConstrainedToWritablePaths(applyPatchArg, writableRoots)) {
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason: "apply_patch command is constrained to writable paths",
|
||||
group: "Editing",
|
||||
runInSandbox: false,
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
}
|
||||
|
||||
return policy === "full-auto"
|
||||
? {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Editing",
|
||||
runInSandbox: true,
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
}
|
||||
: {
|
||||
type: "ask-user",
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All items in `writablePaths` must be absolute paths.
|
||||
*/
|
||||
function isWritePatchConstrainedToWritablePaths(
|
||||
applyPatchArg: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
// `identify_files_needed()` returns a list of files that will be modified or
|
||||
// deleted by the patch, so all of them should already exist on disk. These
|
||||
// candidate paths could be further canonicalized via fs.realpath(), though
|
||||
// that does seem necessary and may even cause false negatives (assuming we
|
||||
// allow writes in other directories that are symlinked from a writable path)
|
||||
//
|
||||
// By comparison, `identify_files_added()` returns a list of files that will
|
||||
// be added by the patch, so they should NOT exist on disk yet and therefore
|
||||
// using one with fs.realpath() should return an error.
|
||||
return (
|
||||
allPathsConstrainedTowritablePaths(
|
||||
identify_files_needed(applyPatchArg),
|
||||
writableRoots,
|
||||
) &&
|
||||
allPathsConstrainedTowritablePaths(
|
||||
identify_files_added(applyPatchArg),
|
||||
writableRoots,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function allPathsConstrainedTowritablePaths(
|
||||
candidatePaths: ReadonlyArray<string>,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
return candidatePaths.every((candidatePath) =>
|
||||
isPathConstrainedTowritablePaths(candidatePath, writableRoots),
|
||||
);
|
||||
}
|
||||
|
||||
/** If candidatePath is relative, it will be resolved against cwd. */
|
||||
function isPathConstrainedTowritablePaths(
|
||||
candidatePath: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
const candidateAbsolutePath = path.resolve(candidatePath);
|
||||
return writableRoots.some((writablePath) =>
|
||||
pathContains(writablePath, candidateAbsolutePath),
|
||||
);
|
||||
}
|
||||
|
||||
/** Both `parent` and `child` must be absolute paths. */
|
||||
function pathContains(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
// relative path doesn't go outside parent
|
||||
!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `bashArg` might be something like "apply_patch << 'EOF' *** Begin...".
|
||||
* If this function returns a string, then it is the content the arg to
|
||||
* apply_patch with the heredoc removed.
|
||||
*/
|
||||
function tryParseApplyPatch(bashArg: string): string | null {
|
||||
const prefix = "apply_patch";
|
||||
if (!bashArg.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const heredoc = bashArg.slice(prefix.length);
|
||||
const heredocMatch = heredoc.match(
|
||||
/^\s*<<\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\1/,
|
||||
);
|
||||
if (heredocMatch != null && typeof heredocMatch[2] === "string") {
|
||||
return heredocMatch[2].trim();
|
||||
} else {
|
||||
return heredoc.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export type SafeCommandReason = {
|
||||
reason: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* If this is a "known safe" command, returns the (reason, group); otherwise,
|
||||
* returns null.
|
||||
*/
|
||||
export function isSafeCommand(
|
||||
command: ReadonlyArray<string>,
|
||||
): SafeCommandReason | null {
|
||||
const [cmd0, cmd1, cmd2, cmd3] = command;
|
||||
|
||||
switch (cmd0) {
|
||||
case "cd":
|
||||
return {
|
||||
reason: "Change directory",
|
||||
group: "Navigating",
|
||||
};
|
||||
case "ls":
|
||||
return {
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
};
|
||||
case "pwd":
|
||||
return {
|
||||
reason: "Print working directory",
|
||||
group: "Navigating",
|
||||
};
|
||||
case "true":
|
||||
return {
|
||||
reason: "No‑op (true)",
|
||||
group: "Utility",
|
||||
};
|
||||
case "echo":
|
||||
return { reason: "Echo string", group: "Printing" };
|
||||
case "cat":
|
||||
return {
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "rg":
|
||||
return {
|
||||
reason: "Ripgrep search",
|
||||
group: "Searching",
|
||||
};
|
||||
case "find":
|
||||
return {
|
||||
reason: "Find files or directories",
|
||||
group: "Searching",
|
||||
};
|
||||
case "grep":
|
||||
return {
|
||||
reason: "Text search (grep)",
|
||||
group: "Searching",
|
||||
};
|
||||
case "head":
|
||||
return {
|
||||
reason: "Show file head",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "tail":
|
||||
return {
|
||||
reason: "Show file tail",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "wc":
|
||||
return {
|
||||
reason: "Word count",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "which":
|
||||
return {
|
||||
reason: "Locate command",
|
||||
group: "Searching",
|
||||
};
|
||||
case "git":
|
||||
switch (cmd1) {
|
||||
case "status":
|
||||
return {
|
||||
reason: "Git status",
|
||||
group: "Versioning",
|
||||
};
|
||||
case "branch":
|
||||
return {
|
||||
reason: "List Git branches",
|
||||
group: "Versioning",
|
||||
};
|
||||
case "log":
|
||||
return {
|
||||
reason: "Git log",
|
||||
group: "Using git",
|
||||
};
|
||||
case "diff":
|
||||
return {
|
||||
reason: "Git diff",
|
||||
group: "Using git",
|
||||
};
|
||||
case "show":
|
||||
return {
|
||||
reason: "Git show",
|
||||
group: "Using git",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "cargo":
|
||||
if (cmd1 === "check") {
|
||||
return {
|
||||
reason: "Cargo check",
|
||||
group: "Running command",
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "sed":
|
||||
if (
|
||||
cmd1 === "-n" &&
|
||||
isValidSedNArg(cmd2) &&
|
||||
typeof cmd3 === "string" &&
|
||||
command.length === 4
|
||||
) {
|
||||
return {
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "oai":
|
||||
switch (cmd1) {
|
||||
case "show-lines":
|
||||
return {
|
||||
reason: "OAI show lines",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "find-files":
|
||||
return {
|
||||
reason: "OAI find files",
|
||||
group: "Searching",
|
||||
};
|
||||
case "file-outline":
|
||||
return {
|
||||
reason: "OAI file outline",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "rg":
|
||||
return {
|
||||
reason: "OAI ripgrep",
|
||||
group: "Searching",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidSedNArg(arg: string | undefined): boolean {
|
||||
return arg != null && /^(\d+,)?\d+p$/.test(arg);
|
||||
}
|
||||
|
||||
// ---------------- Helper utilities for complex shell expressions -----------------
|
||||
|
||||
// A conservative allow‑list of bash operators that do not, on their own, cause
|
||||
// side effects. Redirections (>, >>, <, etc.) and command substitution `$()`
|
||||
// are intentionally excluded. Parentheses used for grouping are treated as
|
||||
// strings by `shell‑quote`, so we do not add them here. Reference:
|
||||
// https://github.com/substack/node-shell-quote#parsecmd-opts
|
||||
const SAFE_SHELL_OPERATORS: ReadonlySet<string> = new Set([
|
||||
"&&", // logical AND
|
||||
"||", // logical OR
|
||||
"|", // pipe
|
||||
";", // command separator
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determines whether a parsed shell expression consists solely of safe
|
||||
* commands (as per `isSafeCommand`) combined using only operators in
|
||||
* `SAFE_SHELL_OPERATORS`.
|
||||
*
|
||||
* If entirely safe, returns the reason/group from the *first* command
|
||||
* segment so callers can surface a meaningful description. Otherwise returns
|
||||
* null.
|
||||
*/
|
||||
function isEntireShellExpressionSafe(
|
||||
parts: ReadonlyArray<ParseEntry>,
|
||||
): SafeCommandReason | null {
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect command segments delimited by operators. `shell‑quote` represents
|
||||
// subshell grouping parentheses as literal strings "(" and ")"; treat them
|
||||
// as unsafe to keep the logic simple (since subshells could introduce
|
||||
// unexpected scope changes).
|
||||
|
||||
let currentSegment: Array<string> = [];
|
||||
let firstReason: SafeCommandReason | null = null;
|
||||
|
||||
const flushSegment = (): boolean => {
|
||||
if (currentSegment.length === 0) {
|
||||
return true; // nothing to validate (possible leading operator)
|
||||
}
|
||||
const assessment = isSafeCommand(currentSegment);
|
||||
if (assessment == null) {
|
||||
return false;
|
||||
}
|
||||
if (firstReason == null) {
|
||||
firstReason = assessment;
|
||||
}
|
||||
currentSegment = [];
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
// If this string looks like an open/close parenthesis or brace, treat as
|
||||
// unsafe to avoid parsing complexity.
|
||||
if (part === "(" || part === ")" || part === "{" || part === "}") {
|
||||
return null;
|
||||
}
|
||||
currentSegment.push(part);
|
||||
} else if (isParseEntryWithOp(part)) {
|
||||
// Validate the segment accumulated so far.
|
||||
if (!flushSegment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate the operator itself.
|
||||
if (!SAFE_SHELL_OPERATORS.has(part.op)) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Unknown token type
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate any trailing command segment.
|
||||
if (!flushSegment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstReason;
|
||||
} catch (_err) {
|
||||
// If there's any kind of failure, just bail out and return null.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime type guard that narrows a `ParseEntry` to the variants that
|
||||
// carry an `op` field. Using a dedicated function avoids the need for
|
||||
// inline type assertions and makes the narrowing reusable and explicit.
|
||||
function isParseEntryWithOp(
|
||||
entry: ParseEntry,
|
||||
): entry is { op: ControlOperator } | { op: "glob"; pattern: string } {
|
||||
return (
|
||||
typeof entry === "object" &&
|
||||
entry != null &&
|
||||
// Using the safe `in` operator keeps the check property‑safe even when
|
||||
// `entry` is a `string`.
|
||||
"op" in entry &&
|
||||
typeof (entry as { op?: unknown }).op === "string"
|
||||
);
|
||||
}
|
||||
21
codex-cli/src/lib/format-command.test.ts
Normal file
21
codex-cli/src/lib/format-command.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { formatCommandForDisplay } from "./format-command";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("formatCommandForDisplay()", () => {
|
||||
test("ensure empty string arg appears in output", () => {
|
||||
expect(formatCommandForDisplay(["echo", ""])).toEqual("echo ''");
|
||||
});
|
||||
|
||||
test("ensure special characters are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "$HOME"])).toEqual("echo \\$HOME");
|
||||
});
|
||||
|
||||
test("ensure quotes are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "I can't believe this."])).toEqual(
|
||||
'echo "I can\'t believe this."',
|
||||
);
|
||||
expect(
|
||||
formatCommandForDisplay(["echo", 'So I said, "No ma\'am!"']),
|
||||
).toEqual('echo "So I said, \\"No ma\'am\\!\\""');
|
||||
});
|
||||
});
|
||||
53
codex-cli/src/lib/format-command.ts
Normal file
53
codex-cli/src/lib/format-command.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
/**
|
||||
* Format the args of an exec command for display as a single string. Prefer
|
||||
* this to doing `args.join(" ")` as this will handle quoting and escaping
|
||||
* correctly. See unit test for details.
|
||||
*/
|
||||
export function formatCommandForDisplay(command: Array<string>): string {
|
||||
// The model often wraps arbitrary shell commands in an invocation that looks
|
||||
// like:
|
||||
//
|
||||
// ["bash", "-lc", "'<actual command>'"]
|
||||
//
|
||||
// When displaying these back to the user, we do NOT want to show the
|
||||
// boiler‑plate "bash -lc" wrapper. Instead, we want to surface only the
|
||||
// actual command that bash will evaluate.
|
||||
|
||||
// Historically we detected this by first quoting the entire command array
|
||||
// with `shell‑quote` and then using a regular expression to peel off the
|
||||
// `bash -lc '…'` prefix. However, that approach was brittle (it depended on
|
||||
// the exact quoting behavior of `shell-quote`) and unnecessarily
|
||||
// inefficient.
|
||||
|
||||
// A simpler and more robust approach is to look at the raw command array
|
||||
// itself. If it matches the shape produced by our exec helpers—exactly three
|
||||
// entries where the first two are «bash» and «-lc»—then we can return the
|
||||
// third entry directly (after stripping surrounding single quotes if they
|
||||
// are present).
|
||||
|
||||
try {
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[0] === "bash" &&
|
||||
command[1] === "-lc" &&
|
||||
typeof command[2] === "string"
|
||||
) {
|
||||
let inner = command[2];
|
||||
|
||||
// Some callers wrap the actual command in single quotes (e.g. `'echo foo'`).
|
||||
// For display purposes we want to drop those outer quotes so that the
|
||||
// rendered command looks exactly like what the user typed.
|
||||
if (inner.startsWith("'") && inner.endsWith("'")) {
|
||||
inner = inner.slice(1, -1);
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
return quote(command);
|
||||
} catch (err) {
|
||||
return command.join(" ");
|
||||
}
|
||||
}
|
||||
45
codex-cli/src/lib/parse-apply-patch.test.ts
Normal file
45
codex-cli/src/lib/parse-apply-patch.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { parseApplyPatch } from "./parse-apply-patch";
|
||||
import { expect, test, describe } from "vitest";
|
||||
|
||||
// Helper function to unwrap a non‑null result in tests that expect success.
|
||||
function mustParse(patch: string) {
|
||||
const parsed = parseApplyPatch(patch);
|
||||
if (parsed == null) {
|
||||
throw new Error(
|
||||
"Expected patch to be valid, but parseApplyPatch returned null",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("parseApplyPatch", () => {
|
||||
test("parses create, update and delete operations in a single patch", () => {
|
||||
const patch = `*** Begin Patch\n*** Add File: created.txt\n+hello\n+world\n*** Update File: updated.txt\n@@\n-old\n+new\n*** Delete File: removed.txt\n*** End Patch`;
|
||||
|
||||
const ops = mustParse(patch);
|
||||
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
type: "create",
|
||||
path: "created.txt",
|
||||
content: "hello\nworld",
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
path: "updated.txt",
|
||||
update: "@@\n-old\n+new",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
},
|
||||
{
|
||||
type: "delete",
|
||||
path: "removed.txt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null for an invalid patch (missing prefix)", () => {
|
||||
const invalid = `*** Add File: foo.txt\n+bar\n*** End Patch`;
|
||||
expect(parseApplyPatch(invalid)).toBeNull();
|
||||
});
|
||||
});
|
||||
112
codex-cli/src/lib/parse-apply-patch.ts
Normal file
112
codex-cli/src/lib/parse-apply-patch.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type ApplyPatchCreateFileOp = {
|
||||
type: "create";
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchDeleteFileOp = {
|
||||
type: "delete";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchUpdateFileOp = {
|
||||
type: "update";
|
||||
path: string;
|
||||
update: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
};
|
||||
|
||||
export type ApplyPatchOp =
|
||||
| ApplyPatchCreateFileOp
|
||||
| ApplyPatchDeleteFileOp
|
||||
| ApplyPatchUpdateFileOp;
|
||||
|
||||
const PATCH_PREFIX = "*** Begin Patch\n";
|
||||
const PATCH_SUFFIX = "\n*** End Patch";
|
||||
const ADD_FILE_PREFIX = "*** Add File: ";
|
||||
const DELETE_FILE_PREFIX = "*** Delete File: ";
|
||||
const UPDATE_FILE_PREFIX = "*** Update File: ";
|
||||
const END_OF_FILE_PREFIX = "*** End of File";
|
||||
const HUNK_ADD_LINE_PREFIX = "+";
|
||||
|
||||
/**
|
||||
* @returns null when the patch is invalid
|
||||
*/
|
||||
export function parseApplyPatch(patch: string): Array<ApplyPatchOp> | null {
|
||||
if (!patch.startsWith(PATCH_PREFIX)) {
|
||||
// Patch must begin with '*** Begin Patch'
|
||||
return null;
|
||||
} else if (!patch.endsWith(PATCH_SUFFIX)) {
|
||||
// Patch must end with '*** End Patch'
|
||||
return null;
|
||||
}
|
||||
|
||||
const patchBody = patch.slice(
|
||||
PATCH_PREFIX.length,
|
||||
patch.length - PATCH_SUFFIX.length,
|
||||
);
|
||||
|
||||
const lines = patchBody.split("\n");
|
||||
|
||||
const ops: Array<ApplyPatchOp> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(END_OF_FILE_PREFIX)) {
|
||||
continue;
|
||||
} else if (line.startsWith(ADD_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "create",
|
||||
path: line.slice(ADD_FILE_PREFIX.length).trim(),
|
||||
content: "",
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(DELETE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "delete",
|
||||
path: line.slice(DELETE_FILE_PREFIX.length).trim(),
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(UPDATE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "update",
|
||||
path: line.slice(UPDATE_FILE_PREFIX.length).trim(),
|
||||
update: "",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastOp = ops[ops.length - 1];
|
||||
|
||||
if (lastOp?.type === "create") {
|
||||
lastOp.content = appendLine(
|
||||
lastOp.content,
|
||||
line.slice(HUNK_ADD_LINE_PREFIX.length),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastOp?.type !== "update") {
|
||||
// Expected update op but got ${lastOp?.type} for line ${line}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith(HUNK_ADD_LINE_PREFIX)) {
|
||||
lastOp.added += 1;
|
||||
} else if (line.startsWith("-")) {
|
||||
lastOp.deleted += 1;
|
||||
}
|
||||
lastOp.update += lastOp.update ? "\n" + line : line;
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
function appendLine(content: string, line: string) {
|
||||
if (!content.length) {
|
||||
return line;
|
||||
}
|
||||
return [content, line].join("\n");
|
||||
}
|
||||
852
codex-cli/src/lib/text-buffer.ts
Normal file
852
codex-cli/src/lib/text-buffer.ts
Normal file
@@ -0,0 +1,852 @@
|
||||
/* eslint‑disable no-bitwise */
|
||||
export type Direction =
|
||||
| "left"
|
||||
| "right"
|
||||
| "up"
|
||||
| "down"
|
||||
| "wordLeft"
|
||||
| "wordRight"
|
||||
| "home"
|
||||
| "end";
|
||||
|
||||
// Simple helper for word‑wise ops.
|
||||
function isWordChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) {
|
||||
return false;
|
||||
}
|
||||
return !/[\s,.;!?]/.test(ch);
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
}
|
||||
|
||||
/*
|
||||
* -------------------------------------------------------------------------
|
||||
* Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
|
||||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
function toCodePoints(str: string): Array<string> {
|
||||
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
||||
// surrogate pairs correctly.
|
||||
return Array.from(str);
|
||||
}
|
||||
|
||||
function cpLen(str: string): number {
|
||||
return toCodePoints(str).length;
|
||||
}
|
||||
|
||||
function cpSlice(str: string, start: number, end?: number): string {
|
||||
// Slice by code‑point indices and re‑join.
|
||||
const arr = toCodePoints(str).slice(start, end);
|
||||
return arr.join("");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
// Enable verbose logging only when requested via env var.
|
||||
const DEBUG =
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true";
|
||||
|
||||
function dbg(...args: Array<unknown>): void {
|
||||
if (DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TextBuffer]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default class TextBuffer {
|
||||
private lines: Array<string>;
|
||||
private cursorRow = 0;
|
||||
private cursorCol = 0;
|
||||
private scrollRow = 0;
|
||||
private scrollCol = 0;
|
||||
|
||||
/**
|
||||
* When the user moves the caret vertically we try to keep their original
|
||||
* horizontal column even when passing through shorter lines. We remember
|
||||
* that *preferred* column in this field while the user is still travelling
|
||||
* vertically. Any explicit horizontal movement resets the preference.
|
||||
*/
|
||||
private preferredCol: number | null = null;
|
||||
|
||||
/* a single integer that bumps every time text changes */
|
||||
private version = 0;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* History & clipboard
|
||||
* ---------------------------------------------------------------- */
|
||||
private undoStack: Array<{ lines: Array<string>; row: number; col: number }> =
|
||||
[];
|
||||
private redoStack: Array<{ lines: Array<string>; row: number; col: number }> =
|
||||
[];
|
||||
private historyLimit = 100;
|
||||
|
||||
private clipboard: string | null = null;
|
||||
|
||||
constructor(text = "") {
|
||||
this.lines = text.split("\n");
|
||||
if (this.lines.length === 0) {
|
||||
this.lines = [""];
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* External editor integration (git‑style $EDITOR workflow)
|
||||
* =================================================================== */
|
||||
|
||||
/**
|
||||
* Opens the current buffer contents in the user’s preferred terminal text
|
||||
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
||||
* until the editor exits, then reloads the file and replaces the in‑memory
|
||||
* buffer with whatever the user saved.
|
||||
*
|
||||
* The operation is treated as a single undoable edit – we snapshot the
|
||||
* previous state *once* before launching the editor so one `undo()` will
|
||||
* revert the entire change set.
|
||||
*
|
||||
* Note: We purposefully rely on the *synchronous* spawn API so that the
|
||||
* calling process genuinely waits for the editor to close before
|
||||
* continuing. This mirrors Git’s behaviour and simplifies downstream
|
||||
* control‑flow (callers can simply `await` the Promise).
|
||||
*/
|
||||
async openInExternalEditor(opts: { editor?: string } = {}): Promise<void> {
|
||||
// Deliberately use `require()` so that unit tests can stub the
|
||||
// respective modules with `vi.spyOn(require("node:child_process"), …)`.
|
||||
// Dynamic `import()` would circumvent those CommonJS stubs.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pathMod = require("node:path");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require("node:fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const os = require("node:os");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const editor =
|
||||
opts.editor ??
|
||||
process.env["VISUAL"] ??
|
||||
process.env["EDITOR"] ??
|
||||
(process.platform === "win32" ? "notepad" : "vi");
|
||||
|
||||
// Prepare a temporary file with the current contents. We use mkdtempSync
|
||||
// to obtain an isolated directory and avoid name collisions.
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "codex-edit-"));
|
||||
const filePath = pathMod.join(tmpDir, "buffer.txt");
|
||||
|
||||
fs.writeFileSync(filePath, this.getText(), "utf8");
|
||||
|
||||
// One snapshot for undo semantics *before* we mutate anything.
|
||||
this.pushUndo();
|
||||
|
||||
// The child inherits stdio so the user can interact with the editor as if
|
||||
// they had launched it directly.
|
||||
const { status, error } = spawnSync(editor, [filePath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (typeof status === "number" && status !== 0) {
|
||||
throw new Error(`External editor exited with status ${status}`);
|
||||
}
|
||||
|
||||
// Read the edited contents back in – normalise line endings to \n.
|
||||
let newText = fs.readFileSync(filePath, "utf8");
|
||||
newText = newText.replace(/\r\n?/g, "\n");
|
||||
|
||||
// Update buffer.
|
||||
this.lines = newText.split("\n");
|
||||
if (this.lines.length === 0) {
|
||||
this.lines = [""];
|
||||
}
|
||||
|
||||
// Position the caret at EOF.
|
||||
this.cursorRow = this.lines.length - 1;
|
||||
this.cursorCol = cpLen(this.line(this.cursorRow));
|
||||
|
||||
// Reset scroll offsets so the new end is visible.
|
||||
this.scrollRow = Math.max(0, this.cursorRow - 1);
|
||||
this.scrollCol = 0;
|
||||
|
||||
this.version++;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Geometry helpers
|
||||
* ===================================================================== */
|
||||
private line(r: number): string {
|
||||
return this.lines[r] ?? "";
|
||||
}
|
||||
private lineLen(r: number): number {
|
||||
return cpLen(this.line(r));
|
||||
}
|
||||
|
||||
private ensureCursorInRange(): void {
|
||||
this.cursorRow = clamp(this.cursorRow, 0, this.lines.length - 1);
|
||||
this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* History helpers
|
||||
* =================================================================== */
|
||||
private snapshot() {
|
||||
return {
|
||||
lines: this.lines.slice(),
|
||||
row: this.cursorRow,
|
||||
col: this.cursorCol,
|
||||
};
|
||||
}
|
||||
|
||||
private pushUndo() {
|
||||
dbg("pushUndo", { cursor: this.getCursor(), text: this.getText() });
|
||||
this.undoStack.push(this.snapshot());
|
||||
if (this.undoStack.length > this.historyLimit) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
// once we mutate we clear redo
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot and return true if restoration happened.
|
||||
*/
|
||||
private restore(
|
||||
state: { lines: Array<string>; row: number; col: number } | undefined,
|
||||
): boolean {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
this.lines = state.lines.slice();
|
||||
this.cursorRow = state.row;
|
||||
this.cursorCol = state.col;
|
||||
this.ensureCursorInRange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Scrolling helpers
|
||||
* ===================================================================== */
|
||||
private ensureCursorVisible(vp: Viewport) {
|
||||
const { height, width } = vp;
|
||||
|
||||
if (this.cursorRow < this.scrollRow) {
|
||||
this.scrollRow = this.cursorRow;
|
||||
} else if (this.cursorRow >= this.scrollRow + height) {
|
||||
this.scrollRow = this.cursorRow - height + 1;
|
||||
}
|
||||
|
||||
if (this.cursorCol < this.scrollCol) {
|
||||
this.scrollCol = this.cursorCol;
|
||||
} else if (this.cursorCol >= this.scrollCol + width) {
|
||||
this.scrollCol = this.cursorCol - width + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Public read‑only accessors
|
||||
* ===================================================================== */
|
||||
getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
getCursor(): [number, number] {
|
||||
return [this.cursorRow, this.cursorCol];
|
||||
}
|
||||
getVisibleLines(vp: Viewport): Array<string> {
|
||||
// Whenever the viewport dimensions change (e.g. on a terminal resize) we
|
||||
// need to re‑evaluate whether the current scroll offset still keeps the
|
||||
// caret visible. Calling `ensureCursorVisible` here guarantees that mere
|
||||
// re‑renders – even when not triggered by user input – will adjust the
|
||||
// horizontal and vertical scroll positions so the cursor remains in view.
|
||||
this.ensureCursorVisible(vp);
|
||||
|
||||
return this.lines.slice(this.scrollRow, this.scrollRow + vp.height);
|
||||
}
|
||||
getText(): string {
|
||||
return this.lines.join("\n");
|
||||
}
|
||||
getLines(): Array<string> {
|
||||
return this.lines.slice();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* History public API – undo / redo
|
||||
* =================================================================== */
|
||||
undo(): boolean {
|
||||
const state = this.undoStack.pop();
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
// push current to redo before restore
|
||||
this.redoStack.push(this.snapshot());
|
||||
this.restore(state);
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const state = this.redoStack.pop();
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
// push current to undo before restore
|
||||
this.undoStack.push(this.snapshot());
|
||||
this.restore(state);
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Editing operations
|
||||
* ===================================================================== */
|
||||
/**
|
||||
* Insert a single character or string without newlines. If the string
|
||||
* contains a newline we delegate to insertStr so that line splitting
|
||||
* logic is shared.
|
||||
*/
|
||||
insert(ch: string): void {
|
||||
// Handle pasted blocks that may contain newline sequences (\n, \r or
|
||||
// Windows‑style \r\n). Delegate to `insertStr` so the splitting logic is
|
||||
// centralised.
|
||||
if (/[\n\r]/.test(ch)) {
|
||||
this.insertStr(ch);
|
||||
return;
|
||||
}
|
||||
|
||||
dbg("insert", { ch, beforeCursor: this.getCursor() });
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + ch + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol += ch.length;
|
||||
this.version++;
|
||||
|
||||
dbg("insert:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
newline(): void {
|
||||
dbg("newline", { beforeCursor: this.getCursor() });
|
||||
this.pushUndo();
|
||||
|
||||
const l = this.line(this.cursorRow);
|
||||
const before = cpSlice(l, 0, this.cursorCol);
|
||||
const after = cpSlice(l, this.cursorCol);
|
||||
|
||||
this.lines[this.cursorRow] = before;
|
||||
this.lines.splice(this.cursorRow + 1, 0, after);
|
||||
|
||||
this.cursorRow += 1;
|
||||
this.cursorCol = 0;
|
||||
this.version++;
|
||||
|
||||
dbg("newline:after", {
|
||||
cursor: this.getCursor(),
|
||||
lines: [this.line(this.cursorRow - 1), this.line(this.cursorRow)],
|
||||
});
|
||||
}
|
||||
|
||||
backspace(): void {
|
||||
dbg("backspace", { beforeCursor: this.getCursor() });
|
||||
if (this.cursorCol === 0 && this.cursorRow === 0) {
|
||||
return;
|
||||
} // nothing to delete
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
if (this.cursorCol > 0) {
|
||||
const line = this.line(this.cursorRow);
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol - 1) + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol--;
|
||||
} else if (this.cursorRow > 0) {
|
||||
// merge with previous
|
||||
const prev = this.line(this.cursorRow - 1);
|
||||
const cur = this.line(this.cursorRow);
|
||||
const newCol = cpLen(prev);
|
||||
this.lines[this.cursorRow - 1] = prev + cur;
|
||||
this.lines.splice(this.cursorRow, 1);
|
||||
this.cursorRow--;
|
||||
this.cursorCol = newCol;
|
||||
}
|
||||
this.version++;
|
||||
|
||||
dbg("backspace:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
del(): void {
|
||||
dbg("delete", { beforeCursor: this.getCursor() });
|
||||
const line = this.line(this.cursorRow);
|
||||
if (this.cursorCol < this.lineLen(this.cursorRow)) {
|
||||
this.pushUndo();
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + cpSlice(line, this.cursorCol + 1);
|
||||
} else if (this.cursorRow < this.lines.length - 1) {
|
||||
this.pushUndo();
|
||||
const next = this.line(this.cursorRow + 1);
|
||||
this.lines[this.cursorRow] = line + next;
|
||||
this.lines.splice(this.cursorRow + 1, 1);
|
||||
}
|
||||
this.version++;
|
||||
|
||||
dbg("delete:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Word‑wise deletion helpers – exposed publicly so tests (and future
|
||||
* key‑bindings) can invoke them directly.
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** Delete the word to the *left* of the caret, mirroring common
|
||||
* Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
|
||||
* whitespace *and* the word characters immediately preceding the caret are
|
||||
* removed. If the caret is already at column‑0 this becomes a no‑op. */
|
||||
deleteWordLeft(): void {
|
||||
dbg("deleteWordLeft", { beforeCursor: this.getCursor() });
|
||||
|
||||
if (this.cursorCol === 0 && this.cursorRow === 0) {
|
||||
return;
|
||||
} // Nothing to delete
|
||||
|
||||
// When at column‑0 but *not* on the first row we merge with the previous
|
||||
// line – matching the behaviour of `backspace` for uniform UX.
|
||||
if (this.cursorCol === 0) {
|
||||
this.backspace();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
const arr = toCodePoints(line);
|
||||
|
||||
// Step 1 – skip over any separators sitting *immediately* to the left of
|
||||
// the caret so that consecutive deletions wipe runs of whitespace first
|
||||
// then words.
|
||||
let start = this.cursorCol;
|
||||
while (start > 0 && !isWordChar(arr[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Step 2 – now skip the word characters themselves.
|
||||
while (start > 0 && isWordChar(arr[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, start) + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol = start;
|
||||
this.version++;
|
||||
|
||||
dbg("deleteWordLeft:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete the word to the *right* of the caret, akin to many editors'
|
||||
* Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
|
||||
* follows the caret and the next contiguous run of word characters. */
|
||||
deleteWordRight(): void {
|
||||
dbg("deleteWordRight", { beforeCursor: this.getCursor() });
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
const arr = toCodePoints(line);
|
||||
if (
|
||||
this.cursorCol >= arr.length &&
|
||||
this.cursorRow === this.lines.length - 1
|
||||
) {
|
||||
return;
|
||||
} // nothing to delete
|
||||
|
||||
// At end‑of‑line ➜ merge with next row (mirrors `del` behaviour).
|
||||
if (this.cursorCol >= arr.length) {
|
||||
this.del();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
let end = this.cursorCol;
|
||||
|
||||
// Skip separators *first* so that consecutive calls gradually chew
|
||||
// through whitespace then whole words.
|
||||
while (end < arr.length && !isWordChar(arr[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// Skip the word characters.
|
||||
while (end < arr.length && isWordChar(arr[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
|
||||
// caret stays in place
|
||||
this.version++;
|
||||
|
||||
dbg("deleteWordRight:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
move(dir: Direction): void {
|
||||
const before = this.getCursor();
|
||||
switch (dir) {
|
||||
case "left":
|
||||
this.preferredCol = null;
|
||||
if (this.cursorCol > 0) {
|
||||
this.cursorCol--;
|
||||
} else if (this.cursorRow > 0) {
|
||||
this.cursorRow--;
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
}
|
||||
break;
|
||||
case "right":
|
||||
this.preferredCol = null;
|
||||
if (this.cursorCol < this.lineLen(this.cursorRow)) {
|
||||
this.cursorCol++;
|
||||
} else if (this.cursorRow < this.lines.length - 1) {
|
||||
this.cursorRow++;
|
||||
this.cursorCol = 0;
|
||||
}
|
||||
break;
|
||||
case "up":
|
||||
if (this.cursorRow > 0) {
|
||||
if (this.preferredCol == null) {
|
||||
this.preferredCol = this.cursorCol;
|
||||
}
|
||||
this.cursorRow--;
|
||||
this.cursorCol = clamp(
|
||||
this.preferredCol,
|
||||
0,
|
||||
this.lineLen(this.cursorRow),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
if (this.cursorRow < this.lines.length - 1) {
|
||||
if (this.preferredCol == null) {
|
||||
this.preferredCol = this.cursorCol;
|
||||
}
|
||||
this.cursorRow++;
|
||||
this.cursorCol = clamp(
|
||||
this.preferredCol,
|
||||
0,
|
||||
this.lineLen(this.cursorRow),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "home":
|
||||
this.preferredCol = null;
|
||||
this.cursorCol = 0;
|
||||
break;
|
||||
case "end":
|
||||
this.preferredCol = null;
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
break;
|
||||
case "wordLeft": {
|
||||
this.preferredCol = null;
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
const slice = cpSlice(
|
||||
this.line(this.cursorRow),
|
||||
0,
|
||||
this.cursorCol,
|
||||
).replace(/[\s,.;!?]+$/, "");
|
||||
let lastIdx = 0;
|
||||
let m;
|
||||
while ((m = regex.exec(slice)) != null) {
|
||||
lastIdx = m.index;
|
||||
}
|
||||
const last = cpLen(slice.slice(0, lastIdx));
|
||||
this.cursorCol = last === 0 ? 0 : last + 1;
|
||||
break;
|
||||
}
|
||||
case "wordRight": {
|
||||
this.preferredCol = null;
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
const l = this.line(this.cursorRow);
|
||||
let moved = false;
|
||||
let m;
|
||||
while ((m = regex.exec(l)) != null) {
|
||||
const cpIdx = cpLen(l.slice(0, m.index));
|
||||
if (cpIdx > this.cursorCol) {
|
||||
// We want to land *at the beginning* of the separator run so that a
|
||||
// subsequent move("right") behaves naturally.
|
||||
this.cursorCol = cpIdx;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!moved) {
|
||||
// No boundary to the right – jump to EOL.
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
dbg("move", { dir, before, after: this.getCursor() });
|
||||
}
|
||||
|
||||
/*
|
||||
* If the user performed any movement other than a consecutive vertical
|
||||
* traversal we clear the preferred column so the next vertical run starts
|
||||
* afresh. The cases that keep the preference already returned earlier.
|
||||
*/
|
||||
if (dir !== "up" && dir !== "down") {
|
||||
this.preferredCol = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Higher‑level helpers
|
||||
* =================================================================== */
|
||||
|
||||
/**
|
||||
* Insert an arbitrary string, possibly containing internal newlines.
|
||||
* Returns true if the buffer was modified.
|
||||
*/
|
||||
insertStr(str: string): boolean {
|
||||
dbg("insertStr", { str, beforeCursor: this.getCursor() });
|
||||
if (str === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalise all newline conventions (\r, \n, \r\n) to a single '\n'.
|
||||
const normalised = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Fast path: resulted in single‑line string ➜ delegate back to insert
|
||||
if (!normalised.includes("\n")) {
|
||||
this.insert(normalised);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const parts = normalised.split("\n");
|
||||
const before = cpSlice(this.line(this.cursorRow), 0, this.cursorCol);
|
||||
const after = cpSlice(this.line(this.cursorRow), this.cursorCol);
|
||||
|
||||
// Replace current line with first part combined with before text
|
||||
this.lines[this.cursorRow] = before + parts[0];
|
||||
|
||||
// Middle lines (if any) are inserted verbatim after current row
|
||||
if (parts.length > 2) {
|
||||
const middle = parts.slice(1, -1);
|
||||
this.lines.splice(this.cursorRow + 1, 0, ...middle);
|
||||
}
|
||||
|
||||
// Smart handling of the *final* inserted part:
|
||||
// • When the caret is mid‑line we preserve existing behaviour – merge
|
||||
// the last part with the text to the **right** of the caret so that
|
||||
// inserting in the middle of a line keeps the remainder on the same
|
||||
// row (e.g. "he|llo" → paste "x\ny" ⇒ "he x", "y llo").
|
||||
// • When the caret is at column‑0 we instead treat the current line as
|
||||
// a *separate* row that follows the inserted block. This mirrors
|
||||
// common editor behaviour and avoids the unintuitive merge that led
|
||||
// to "cd"+"ef" → "cdef" in the failing tests.
|
||||
|
||||
// Append the last part combined with original after text as a new line
|
||||
const last = parts[parts.length - 1] + after;
|
||||
this.lines.splice(this.cursorRow + (parts.length - 1), 0, last);
|
||||
|
||||
// Update cursor position to end of last inserted part (before 'after')
|
||||
this.cursorRow += parts.length - 1;
|
||||
// `parts` is guaranteed to have at least one element here because
|
||||
// `split("\n")` always returns an array with ≥1 entry. Tell the
|
||||
// compiler so we can pass a plain `string` to `cpLen`.
|
||||
this.cursorCol = cpLen(parts[parts.length - 1]!);
|
||||
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Selection & clipboard helpers (minimal)
|
||||
* =================================================================== */
|
||||
|
||||
private selectionAnchor: [number, number] | null = null;
|
||||
|
||||
startSelection(): void {
|
||||
this.selectionAnchor = [this.cursorRow, this.cursorCol];
|
||||
}
|
||||
|
||||
endSelection(): void {
|
||||
// no‑op for now, kept for API symmetry
|
||||
// we rely on anchor + current cursor to compute selection
|
||||
}
|
||||
|
||||
/** Extract selected text. Returns null if no valid selection. */
|
||||
private getSelectedText(): string | null {
|
||||
if (!this.selectionAnchor) {
|
||||
return null;
|
||||
}
|
||||
const [ar, ac] = this.selectionAnchor;
|
||||
const [br, bc] = [this.cursorRow, this.cursorCol];
|
||||
|
||||
// Determine ordering
|
||||
if (ar === br && ac === bc) {
|
||||
return null;
|
||||
} // empty selection
|
||||
|
||||
const topBefore = ar < br || (ar === br && ac < bc);
|
||||
const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
|
||||
|
||||
if (sr === er) {
|
||||
return cpSlice(this.line(sr), sc, ec);
|
||||
}
|
||||
|
||||
const parts: Array<string> = [];
|
||||
parts.push(cpSlice(this.line(sr), sc));
|
||||
for (let r = sr + 1; r < er; r++) {
|
||||
parts.push(this.line(r));
|
||||
}
|
||||
parts.push(cpSlice(this.line(er), 0, ec));
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
copy(): string | null {
|
||||
const txt = this.getSelectedText();
|
||||
if (txt == null) {
|
||||
return null;
|
||||
}
|
||||
this.clipboard = txt;
|
||||
return txt;
|
||||
}
|
||||
|
||||
paste(): boolean {
|
||||
if (this.clipboard == null) {
|
||||
return false;
|
||||
}
|
||||
return this.insertStr(this.clipboard);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* High level "handleInput" – receives what Ink gives us
|
||||
* Returns true when buffer mutated (=> re‑render)
|
||||
* ===================================================================== */
|
||||
handleInput(
|
||||
input: string | undefined,
|
||||
key: Record<string, boolean>,
|
||||
vp: Viewport,
|
||||
): boolean {
|
||||
if (DEBUG) {
|
||||
dbg("handleInput", { input, key, cursor: this.getCursor() });
|
||||
}
|
||||
const beforeVer = this.version;
|
||||
const [beforeRow, beforeCol] = this.getCursor();
|
||||
|
||||
if (key["escape"]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* new line — Ink sets either `key.return` *or* passes a literal "\n" */
|
||||
if (key["return"] || input === "\r" || input === "\n") {
|
||||
this.newline();
|
||||
} else if (
|
||||
key["leftArrow"] &&
|
||||
!key["meta"] &&
|
||||
!key["ctrl"] &&
|
||||
!key["alt"]
|
||||
) {
|
||||
/* navigation */
|
||||
this.move("left");
|
||||
} else if (
|
||||
key["rightArrow"] &&
|
||||
!key["meta"] &&
|
||||
!key["ctrl"] &&
|
||||
!key["alt"]
|
||||
) {
|
||||
this.move("right");
|
||||
} else if (key["upArrow"]) {
|
||||
this.move("up");
|
||||
} else if (key["downArrow"]) {
|
||||
this.move("down");
|
||||
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["leftArrow"]) {
|
||||
this.move("wordLeft");
|
||||
} else if (
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
key["rightArrow"]
|
||||
) {
|
||||
this.move("wordRight");
|
||||
} else if (key["home"]) {
|
||||
this.move("home");
|
||||
} else if (key["end"]) {
|
||||
this.move("end");
|
||||
}
|
||||
/* delete */
|
||||
// In raw terminal mode many frameworks (Ink included) surface a physical
|
||||
// Backspace key‑press as the single DEL (0x7f) byte placed in `input` with
|
||||
// no `key.backspace` flag set. Treat that byte exactly like an ordinary
|
||||
// Backspace for parity with textarea.rs and to make interactive tests
|
||||
// feedable through the simpler `(ch, {}, vp)` path.
|
||||
else if (
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
(key["backspace"] || input === "\x7f")
|
||||
) {
|
||||
this.deleteWordLeft();
|
||||
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["delete"]) {
|
||||
this.deleteWordRight();
|
||||
} else if (
|
||||
key["backspace"] ||
|
||||
input === "\x7f" ||
|
||||
(key["delete"] && !key["shift"])
|
||||
) {
|
||||
// Treat un‑modified "delete" (the common Mac backspace key) as a
|
||||
// standard backspace. Holding Shift+Delete continues to perform a
|
||||
// forward deletion so we don't lose that capability on keyboards that
|
||||
// expose both behaviours.
|
||||
this.backspace();
|
||||
}
|
||||
// Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after
|
||||
// the branch above) – remove the character *under / to the right* of the
|
||||
// caret, merging lines when at EOL similar to many editors.
|
||||
else if (key["delete"]) {
|
||||
this.del();
|
||||
} else if (input && !key["ctrl"] && !key["meta"]) {
|
||||
this.insert(input);
|
||||
}
|
||||
|
||||
/* printable */
|
||||
|
||||
/* clamp + scroll */
|
||||
this.ensureCursorInRange();
|
||||
this.ensureCursorVisible(vp);
|
||||
|
||||
const cursorMoved =
|
||||
this.cursorRow !== beforeRow || this.cursorCol !== beforeCol;
|
||||
|
||||
if (DEBUG) {
|
||||
dbg("handleInput:after", {
|
||||
cursor: this.getCursor(),
|
||||
text: this.getText(),
|
||||
});
|
||||
}
|
||||
return this.version !== beforeVer || cursorMoved;
|
||||
}
|
||||
}
|
||||
65
codex-cli/src/typings.d.ts
vendored
Normal file
65
codex-cli/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
// Project‑local declaration stubs for external libraries that do not ship
|
||||
// with TypeScript type definitions. These are intentionally minimal – they
|
||||
// cover only the APIs that the Codex codebase relies on. If full type
|
||||
// packages (e.g. `@types/shell‑quote`) are introduced later these stubs will
|
||||
// be overridden automatically by the higher‑priority package typings.
|
||||
|
||||
declare module "shell-quote" {
|
||||
/**
|
||||
* Very small subset of the return tokens produced by `shell‑quote` that are
|
||||
* relevant for our inspection of shell operators. A token can either be a
|
||||
* simple string (command/argument) or an operator object such as
|
||||
* `{ op: "&&" }`.
|
||||
*/
|
||||
export type Token = string | { op: string };
|
||||
|
||||
// Historically the original `shell-quote` library exports several internal
|
||||
// type definitions. We recreate the few that Codex‑Lib imports so that the
|
||||
// TypeScript compiler can resolve them.
|
||||
|
||||
/*
|
||||
* The real `shell‑quote` types define `ControlOperator` as the literal set
|
||||
* of operator strings that can appear in the parsed output. Re‑creating the
|
||||
* exhaustive union is unnecessary for our purposes – modelling it as a
|
||||
* plain string is sufficient for type‑checking the Codex codebase while
|
||||
* still preserving basic safety (the operator string gets validated at
|
||||
* runtime anyway).
|
||||
*/
|
||||
export type ControlOperator = "&&" | "||" | "|" | ";" | string;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ParseEntry = string | { op: ControlOperator } | any;
|
||||
|
||||
/**
|
||||
* Parse a shell command string into tokens. The implementation provided by
|
||||
* the `shell‑quote` package supports additional token kinds (glob, comment,
|
||||
* redirection …) which we deliberately omit here because Codex never
|
||||
* inspects them.
|
||||
*/
|
||||
export function parse(
|
||||
cmd: string,
|
||||
env?: Record<string, string | undefined>,
|
||||
): Array<Token>;
|
||||
|
||||
/**
|
||||
* Quote an array of arguments such that it can be copied & pasted into a
|
||||
* POSIX‑compatible shell.
|
||||
*/
|
||||
export function quote(args: ReadonlyArray<string>): string;
|
||||
}
|
||||
|
||||
declare module "diff" {
|
||||
/**
|
||||
* Minimal stub for the `diff` library which we use only for generating a
|
||||
* unified patch between two in‑memory strings.
|
||||
*/
|
||||
export function createTwoFilesPatch(
|
||||
oldFileName: string,
|
||||
newFileName: string,
|
||||
oldStr: string,
|
||||
newStr: string,
|
||||
oldHeader?: string,
|
||||
newHeader?: string,
|
||||
options?: { context?: number },
|
||||
): string;
|
||||
}
|
||||
1022
codex-cli/src/utils/agent/agent-loop.ts
Normal file
1022
codex-cli/src/utils/agent/agent-loop.ts
Normal file
File diff suppressed because it is too large
Load Diff
644
codex-cli/src/utils/agent/apply-patch.ts
Normal file
644
codex-cli/src/utils/agent/apply-patch.ts
Normal file
@@ -0,0 +1,644 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types & Models
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export enum ActionType {
|
||||
ADD = "add",
|
||||
DELETE = "delete",
|
||||
UPDATE = "update",
|
||||
}
|
||||
|
||||
export interface FileChange {
|
||||
type: ActionType;
|
||||
old_content?: string | null;
|
||||
new_content?: string | null;
|
||||
move_path?: string | null;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
changes: Record<string, FileChange>;
|
||||
}
|
||||
|
||||
export function assemble_changes(
|
||||
orig: Record<string, string | null>,
|
||||
updatedFiles: Record<string, string | null>,
|
||||
): Commit {
|
||||
const commit: Commit = { changes: {} };
|
||||
for (const [p, newContent] of Object.entries(updatedFiles)) {
|
||||
const oldContent = orig[p];
|
||||
if (oldContent === newContent) {
|
||||
continue;
|
||||
}
|
||||
if (oldContent !== undefined && newContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.UPDATE,
|
||||
old_content: oldContent,
|
||||
new_content: newContent,
|
||||
};
|
||||
} else if (newContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.ADD,
|
||||
new_content: newContent,
|
||||
};
|
||||
} else if (oldContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.DELETE,
|
||||
old_content: oldContent,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Unexpected state in assemble_changes");
|
||||
}
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Patch‑related structures
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface Chunk {
|
||||
orig_index: number; // line index of the first line in the original file
|
||||
del_lines: Array<string>;
|
||||
ins_lines: Array<string>;
|
||||
}
|
||||
|
||||
export interface PatchAction {
|
||||
type: ActionType;
|
||||
new_file?: string | null;
|
||||
chunks: Array<Chunk>;
|
||||
move_path?: string | null;
|
||||
}
|
||||
|
||||
export interface Patch {
|
||||
actions: Record<string, PatchAction>;
|
||||
}
|
||||
|
||||
export class DiffError extends Error {}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Parser (patch text -> Patch)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
class Parser {
|
||||
current_files: Record<string, string>;
|
||||
lines: Array<string>;
|
||||
index = 0;
|
||||
patch: Patch = { actions: {} };
|
||||
fuzz = 0;
|
||||
|
||||
constructor(currentFiles: Record<string, string>, lines: Array<string>) {
|
||||
this.current_files = currentFiles;
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
private is_done(prefixes?: Array<string>): boolean {
|
||||
if (this.index >= this.lines.length) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
prefixes &&
|
||||
prefixes.some((p) => this.lines[this.index]!.startsWith(p))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private startswith(prefix: string | Array<string>): boolean {
|
||||
const prefixes = Array.isArray(prefix) ? prefix : [prefix];
|
||||
return prefixes.some((p) => this.lines[this.index]!.startsWith(p));
|
||||
}
|
||||
|
||||
private read_str(prefix = "", returnEverything = false): string {
|
||||
if (this.index >= this.lines.length) {
|
||||
throw new DiffError(`Index: ${this.index} >= ${this.lines.length}`);
|
||||
}
|
||||
if (this.lines[this.index]!.startsWith(prefix)) {
|
||||
const text = returnEverything
|
||||
? this.lines[this.index]
|
||||
: this.lines[this.index]!.slice(prefix.length);
|
||||
this.index += 1;
|
||||
return text ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
parse(): void {
|
||||
while (!this.is_done(["*** End Patch"])) {
|
||||
let path = this.read_str("*** Update File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Update File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
const moveTo = this.read_str("*** Move to: ");
|
||||
if (!(path in this.current_files)) {
|
||||
throw new DiffError(`Update File Error: Missing File: ${path}`);
|
||||
}
|
||||
const text = this.current_files[path];
|
||||
const action = this.parse_update_file(text ?? "");
|
||||
action.move_path = moveTo || undefined;
|
||||
this.patch.actions[path] = action;
|
||||
continue;
|
||||
}
|
||||
path = this.read_str("*** Delete File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Delete File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
if (!(path in this.current_files)) {
|
||||
throw new DiffError(`Delete File Error: Missing File: ${path}`);
|
||||
}
|
||||
this.patch.actions[path] = { type: ActionType.DELETE, chunks: [] };
|
||||
continue;
|
||||
}
|
||||
path = this.read_str("*** Add File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Add File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
if (path in this.current_files) {
|
||||
throw new DiffError(`Add File Error: File already exists: ${path}`);
|
||||
}
|
||||
this.patch.actions[path] = this.parse_add_file();
|
||||
continue;
|
||||
}
|
||||
throw new DiffError(`Unknown Line: ${this.lines[this.index]}`);
|
||||
}
|
||||
if (!this.startswith("*** End Patch")) {
|
||||
throw new DiffError("Missing End Patch");
|
||||
}
|
||||
this.index += 1;
|
||||
}
|
||||
|
||||
private parse_update_file(text: string): PatchAction {
|
||||
const action: PatchAction = { type: ActionType.UPDATE, chunks: [] };
|
||||
const fileLines = text.split("\n");
|
||||
let index = 0;
|
||||
|
||||
while (
|
||||
!this.is_done([
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
"*** End of File",
|
||||
])
|
||||
) {
|
||||
const defStr = this.read_str("@@ ");
|
||||
let sectionStr = "";
|
||||
if (!defStr && this.lines[this.index] === "@@") {
|
||||
sectionStr = this.lines[this.index]!;
|
||||
this.index += 1;
|
||||
}
|
||||
if (!(defStr || sectionStr || index === 0)) {
|
||||
throw new DiffError(`Invalid Line:\n${this.lines[this.index]}`);
|
||||
}
|
||||
if (defStr.trim()) {
|
||||
let found = false;
|
||||
if (!fileLines.slice(0, index).some((s) => s === defStr)) {
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (fileLines[i] === defStr) {
|
||||
index = i + 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!found &&
|
||||
!fileLines.slice(0, index).some((s) => s.trim() === defStr.trim())
|
||||
) {
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (fileLines[i]!.trim() === defStr.trim()) {
|
||||
index = i + 1;
|
||||
this.fuzz += 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [nextChunkContext, chunks, endPatchIndex, eof] = peek_next_section(
|
||||
this.lines,
|
||||
this.index,
|
||||
);
|
||||
const [newIndex, fuzz] = find_context(
|
||||
fileLines,
|
||||
nextChunkContext,
|
||||
index,
|
||||
eof,
|
||||
);
|
||||
if (newIndex === -1) {
|
||||
const ctxText = nextChunkContext.join("\n");
|
||||
if (eof) {
|
||||
throw new DiffError(`Invalid EOF Context ${index}:\n${ctxText}`);
|
||||
} else {
|
||||
throw new DiffError(`Invalid Context ${index}:\n${ctxText}`);
|
||||
}
|
||||
}
|
||||
this.fuzz += fuzz;
|
||||
for (const ch of chunks) {
|
||||
ch.orig_index += newIndex;
|
||||
action.chunks.push(ch);
|
||||
}
|
||||
index = newIndex + nextChunkContext.length;
|
||||
this.index = endPatchIndex;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
private parse_add_file(): PatchAction {
|
||||
const lines: Array<string> = [];
|
||||
while (
|
||||
!this.is_done([
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
])
|
||||
) {
|
||||
const s = this.read_str();
|
||||
if (!s.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File Line: ${s}`);
|
||||
}
|
||||
lines.push(s.slice(1));
|
||||
}
|
||||
return {
|
||||
type: ActionType.ADD,
|
||||
new_file: lines.join("\n"),
|
||||
chunks: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function find_context_core(
|
||||
lines: Array<string>,
|
||||
context: Array<string>,
|
||||
start: number,
|
||||
): [number, number] {
|
||||
if (context.length === 0) {
|
||||
return [start, 0];
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (lines.slice(i, i + context.length).join("\n") === context.join("\n")) {
|
||||
return [i, 0];
|
||||
}
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trimEnd())
|
||||
.join("\n") === context.map((s) => s.trimEnd()).join("\n")
|
||||
) {
|
||||
return [i, 1];
|
||||
}
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trim())
|
||||
.join("\n") === context.map((s) => s.trim()).join("\n")
|
||||
) {
|
||||
return [i, 100];
|
||||
}
|
||||
}
|
||||
return [-1, 0];
|
||||
}
|
||||
|
||||
function find_context(
|
||||
lines: Array<string>,
|
||||
context: Array<string>,
|
||||
start: number,
|
||||
eof: boolean,
|
||||
): [number, number] {
|
||||
if (eof) {
|
||||
let [newIndex, fuzz] = find_context_core(
|
||||
lines,
|
||||
context,
|
||||
lines.length - context.length,
|
||||
);
|
||||
if (newIndex !== -1) {
|
||||
return [newIndex, fuzz];
|
||||
}
|
||||
[newIndex, fuzz] = find_context_core(lines, context, start);
|
||||
return [newIndex, fuzz + 10000];
|
||||
}
|
||||
return find_context_core(lines, context, start);
|
||||
}
|
||||
|
||||
function peek_next_section(
|
||||
lines: Array<string>,
|
||||
initialIndex: number,
|
||||
): [Array<string>, Array<Chunk>, number, boolean] {
|
||||
let index = initialIndex;
|
||||
const old: Array<string> = [];
|
||||
let delLines: Array<string> = [];
|
||||
let insLines: Array<string> = [];
|
||||
const chunks: Array<Chunk> = [];
|
||||
let mode: "keep" | "add" | "delete" = "keep";
|
||||
|
||||
while (index < lines.length) {
|
||||
const s = lines[index]!;
|
||||
if (
|
||||
s.startsWith("@@") ||
|
||||
s.startsWith("*** End Patch") ||
|
||||
s.startsWith("*** Update File:") ||
|
||||
s.startsWith("*** Delete File:") ||
|
||||
s.startsWith("*** Add File:") ||
|
||||
s.startsWith("*** End of File")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (s === "***") {
|
||||
break;
|
||||
}
|
||||
if (s.startsWith("***")) {
|
||||
throw new DiffError(`Invalid Line: ${s}`);
|
||||
}
|
||||
index += 1;
|
||||
const lastMode: "keep" | "add" | "delete" = mode;
|
||||
let line = s;
|
||||
if (line[0] === "+") {
|
||||
mode = "add";
|
||||
} else if (line[0] === "-") {
|
||||
mode = "delete";
|
||||
} else if (line[0] === " ") {
|
||||
mode = "keep";
|
||||
} else {
|
||||
// Tolerate invalid lines where the leading whitespace is missing. This is necessary as
|
||||
// the model sometimes doesn't fully adhere to the spec and returns lines without leading
|
||||
// whitespace for context lines.
|
||||
mode = "keep";
|
||||
line = " " + line;
|
||||
|
||||
// TODO: Re-enable strict mode.
|
||||
// throw new DiffError(`Invalid Line: ${line}`)
|
||||
}
|
||||
|
||||
line = line.slice(1);
|
||||
if (mode === "keep" && lastMode !== mode) {
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
orig_index: old.length - delLines.length,
|
||||
del_lines: delLines,
|
||||
ins_lines: insLines,
|
||||
});
|
||||
}
|
||||
delLines = [];
|
||||
insLines = [];
|
||||
}
|
||||
if (mode === "delete") {
|
||||
delLines.push(line);
|
||||
old.push(line);
|
||||
} else if (mode === "add") {
|
||||
insLines.push(line);
|
||||
} else {
|
||||
old.push(line);
|
||||
}
|
||||
}
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
orig_index: old.length - delLines.length,
|
||||
del_lines: delLines,
|
||||
ins_lines: insLines,
|
||||
});
|
||||
}
|
||||
if (index < lines.length && lines[index] === "*** End of File") {
|
||||
index += 1;
|
||||
return [old, chunks, index, true];
|
||||
}
|
||||
return [old, chunks, index, false];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// High‑level helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function text_to_patch(
|
||||
text: string,
|
||||
orig: Record<string, string>,
|
||||
): [Patch, number] {
|
||||
const lines = text.trim().split("\n");
|
||||
if (
|
||||
lines.length < 2 ||
|
||||
!(lines[0] ?? "").startsWith("*** Begin Patch") ||
|
||||
lines[lines.length - 1] !== "*** End Patch"
|
||||
) {
|
||||
throw new DiffError("Invalid patch text");
|
||||
}
|
||||
const parser = new Parser(orig, lines);
|
||||
parser.index = 1;
|
||||
parser.parse();
|
||||
return [parser.patch, parser.fuzz];
|
||||
}
|
||||
|
||||
export function identify_files_needed(text: string): Array<string> {
|
||||
const lines = text.trim().split("\n");
|
||||
const result = new Set<string>();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
result.add(line.slice("*** Update File: ".length));
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
result.add(line.slice("*** Delete File: ".length));
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
export function identify_files_added(text: string): Array<string> {
|
||||
const lines = text.trim().split("\n");
|
||||
const result = new Set<string>();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
result.add(line.slice("*** Add File: ".length));
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function _get_updated_file(
|
||||
text: string,
|
||||
action: PatchAction,
|
||||
path: string,
|
||||
): string {
|
||||
if (action.type !== ActionType.UPDATE) {
|
||||
throw new Error("Expected UPDATE action");
|
||||
}
|
||||
const origLines = text.split("\n");
|
||||
const destLines: Array<string> = [];
|
||||
let origIndex = 0;
|
||||
for (const chunk of action.chunks) {
|
||||
if (chunk.orig_index > origLines.length) {
|
||||
throw new DiffError(
|
||||
`${path}: chunk.orig_index ${chunk.orig_index} > len(lines) ${origLines.length}`,
|
||||
);
|
||||
}
|
||||
if (origIndex > chunk.orig_index) {
|
||||
throw new DiffError(
|
||||
`${path}: orig_index ${origIndex} > chunk.orig_index ${chunk.orig_index}`,
|
||||
);
|
||||
}
|
||||
destLines.push(...origLines.slice(origIndex, chunk.orig_index));
|
||||
const delta = chunk.orig_index - origIndex;
|
||||
origIndex += delta;
|
||||
|
||||
// inserted lines
|
||||
if (chunk.ins_lines.length) {
|
||||
for (const l of chunk.ins_lines) {
|
||||
destLines.push(l);
|
||||
}
|
||||
}
|
||||
origIndex += chunk.del_lines.length;
|
||||
}
|
||||
destLines.push(...origLines.slice(origIndex));
|
||||
return destLines.join("\n");
|
||||
}
|
||||
|
||||
export function patch_to_commit(
|
||||
patch: Patch,
|
||||
orig: Record<string, string>,
|
||||
): Commit {
|
||||
const commit: Commit = { changes: {} };
|
||||
for (const [pathKey, action] of Object.entries(patch.actions)) {
|
||||
if (action.type === ActionType.DELETE) {
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.DELETE,
|
||||
old_content: orig[pathKey],
|
||||
};
|
||||
} else if (action.type === ActionType.ADD) {
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.ADD,
|
||||
new_content: action.new_file ?? "",
|
||||
};
|
||||
} else if (action.type === ActionType.UPDATE) {
|
||||
const newContent = _get_updated_file(orig[pathKey]!, action, pathKey);
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.UPDATE,
|
||||
old_content: orig[pathKey],
|
||||
new_content: newContent,
|
||||
move_path: action.move_path ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Filesystem helpers for Node environment
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function load_files(
|
||||
paths: Array<string>,
|
||||
openFn: (p: string) => string,
|
||||
): Record<string, string> {
|
||||
const orig: Record<string, string> = {};
|
||||
for (const p of paths) {
|
||||
try {
|
||||
orig[p] = openFn(p);
|
||||
} catch {
|
||||
// Convert any file read error into a DiffError so that callers
|
||||
// consistently receive DiffError for patch-related failures.
|
||||
throw new DiffError(`File not found: ${p}`);
|
||||
}
|
||||
}
|
||||
return orig;
|
||||
}
|
||||
|
||||
export function apply_commit(
|
||||
commit: Commit,
|
||||
writeFn: (p: string, c: string) => void,
|
||||
removeFn: (p: string) => void,
|
||||
): void {
|
||||
for (const [p, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === ActionType.DELETE) {
|
||||
removeFn(p);
|
||||
} else if (change.type === ActionType.ADD) {
|
||||
writeFn(p, change.new_content ?? "");
|
||||
} else if (change.type === ActionType.UPDATE) {
|
||||
if (change.move_path) {
|
||||
writeFn(change.move_path, change.new_content ?? "");
|
||||
removeFn(p);
|
||||
} else {
|
||||
writeFn(p, change.new_content ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function process_patch(
|
||||
text: string,
|
||||
openFn: (p: string) => string,
|
||||
writeFn: (p: string, c: string) => void,
|
||||
removeFn: (p: string) => void,
|
||||
): string {
|
||||
if (!text.startsWith("*** Begin Patch")) {
|
||||
throw new DiffError("Patch must start with *** Begin Patch");
|
||||
}
|
||||
const paths = identify_files_needed(text);
|
||||
const orig = load_files(paths, openFn);
|
||||
const [patch, _fuzz] = text_to_patch(text, orig);
|
||||
const commit = patch_to_commit(patch, orig);
|
||||
apply_commit(commit, writeFn, removeFn);
|
||||
return "Done!";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Default filesystem implementations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function open_file(p: string): string {
|
||||
return fs.readFileSync(p, "utf8");
|
||||
}
|
||||
|
||||
function write_file(p: string, content: string): void {
|
||||
if (path.isAbsolute(p)) {
|
||||
throw new DiffError("We do not support absolute paths.");
|
||||
}
|
||||
const parent = path.dirname(p);
|
||||
if (parent !== ".") {
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
function remove_file(p: string): void {
|
||||
fs.unlinkSync(p);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CLI mode. Not exported, executed only if run directly.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
let patchText = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (patchText += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (!patchText) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Please pass patch text through stdin");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = process_patch(
|
||||
patchText,
|
||||
open_file,
|
||||
write_file,
|
||||
remove_file,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(result);
|
||||
} catch (err: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
67
codex-cli/src/utils/agent/exec.ts
Normal file
67
codex-cli/src/utils/agent/exec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
import { process_patch } from "./apply-patch.js";
|
||||
import { SandboxType } from "./sandbox/interface.js";
|
||||
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
|
||||
import { exec as rawExec } from "./sandbox/raw-exec.js";
|
||||
import { formatCommandForDisplay } from "@lib/format-command.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
|
||||
|
||||
/**
|
||||
* This function should never return a rejected promise: errors should be
|
||||
* mapped to a non-zero exit code and the error message should be in stderr.
|
||||
*/
|
||||
export function exec(
|
||||
{ cmd, workdir, timeoutInMillis }: ExecInput,
|
||||
sandbox: SandboxType,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
// This is a temporary measure to understand what are the common base commands
|
||||
// until we start persisting and uploading rollouts
|
||||
|
||||
const execForSandbox =
|
||||
sandbox === SandboxType.MACOS_SEATBELT ? execWithSeatbelt : rawExec;
|
||||
|
||||
const opts: SpawnOptions = {
|
||||
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
|
||||
...(workdir ? { cwd: workdir } : {}),
|
||||
};
|
||||
const writableRoots = [process.cwd(), os.tmpdir()];
|
||||
return execForSandbox(cmd, opts, writableRoots, abortSignal);
|
||||
}
|
||||
|
||||
export function execApplyPatch(patchText: string): ExecResult {
|
||||
// This is a temporary measure to understand what are the common base commands
|
||||
// until we start persisting and uploading rollouts
|
||||
|
||||
try {
|
||||
const result = process_patch(
|
||||
patchText,
|
||||
(p) => fs.readFileSync(p, "utf8"),
|
||||
(p, c) => fs.writeFileSync(p, c, "utf8"),
|
||||
(p) => fs.unlinkSync(p),
|
||||
);
|
||||
return {
|
||||
stdout: result,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// @ts-expect-error error might not be an object or have a message property.
|
||||
const stderr = String(error.message ?? error);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: stderr,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getBaseCmd(cmd: Array<string>): string {
|
||||
const formattedCommand = formatCommandForDisplay(cmd);
|
||||
return formattedCommand.split(" ")[0] || cmd[0] || "<unknown>";
|
||||
}
|
||||
315
codex-cli/src/utils/agent/handle-exec-command.ts
Normal file
315
codex-cli/src/utils/agent/handle-exec-command.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { CommandConfirmation } from "./agent-loop.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { ExecInput } from "./sandbox/interface.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "@lib/approvals.js";
|
||||
import type { ResponseInputItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { exec, execApplyPatch } from "./exec.js";
|
||||
import { isLoggingEnabled, log } from "./log.js";
|
||||
import { ReviewDecision } from "./review.js";
|
||||
import { FullAutoErrorMode } from "../auto-approval-mode.js";
|
||||
import { SandboxType } from "./sandbox/interface.js";
|
||||
import { canAutoApprove } from "@lib/approvals.js";
|
||||
import { formatCommandForDisplay } from "@lib/format-command.js";
|
||||
import { access } from "fs/promises";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session‑level cache of commands that the user has chosen to always approve.
|
||||
//
|
||||
// The values are derived via `deriveCommandKey()` which intentionally ignores
|
||||
// volatile arguments (for example the patch text passed to `apply_patch`).
|
||||
// Storing *generalised* keys means that once a user selects "always approve"
|
||||
// for a given class of command we will genuinely stop prompting them for
|
||||
// subsequent, equivalent invocations during the same CLI session.
|
||||
// ---------------------------------------------------------------------------
|
||||
const alwaysApprovedCommands = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Given the argv-style representation of a command, return a stable
|
||||
// string key that can be used for equality checks.
|
||||
//
|
||||
// The key space purposefully abstracts away parts of the command line that
|
||||
// are expected to change between invocations while still retaining enough
|
||||
// information to differentiate *meaningfully distinct* operations. See the
|
||||
// extensive inline documentation for details.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deriveCommandKey(cmd: Array<string>): string {
|
||||
// pull off only the bits you care about
|
||||
const [
|
||||
maybeShell,
|
||||
maybeFlag,
|
||||
coreInvocation,
|
||||
/* …ignore the rest… */
|
||||
] = cmd;
|
||||
|
||||
if (coreInvocation?.startsWith("apply_patch")) {
|
||||
return "apply_patch";
|
||||
}
|
||||
|
||||
if (maybeShell === "bash" && maybeFlag === "-lc") {
|
||||
// If the command was invoked through `bash -lc "<script>"` we extract the
|
||||
// base program name from the script string.
|
||||
const script = coreInvocation ?? "";
|
||||
return script.split(/\s+/)[0] || "bash";
|
||||
}
|
||||
|
||||
// For every other command we fall back to using only the program name (the
|
||||
// first argv element). This guarantees we always return a *string* even if
|
||||
// `coreInvocation` is undefined.
|
||||
if (coreInvocation) {
|
||||
return coreInvocation.split(/\s+/)[0]!;
|
||||
}
|
||||
|
||||
return JSON.stringify(cmd);
|
||||
}
|
||||
|
||||
type HandleExecCommandResult = {
|
||||
outputText: string;
|
||||
metadata: Record<string, unknown>;
|
||||
additionalItems?: Array<ResponseInputItem>;
|
||||
};
|
||||
|
||||
export async function handleExecCommand(
|
||||
args: ExecInput,
|
||||
config: AppConfig,
|
||||
policy: ApprovalPolicy,
|
||||
getCommandConfirmation: (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
) => Promise<CommandConfirmation>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<HandleExecCommandResult> {
|
||||
const { cmd: command } = args;
|
||||
|
||||
const key = deriveCommandKey(command);
|
||||
|
||||
// 1) If the user has already said "always approve", skip
|
||||
// any policy & never sandbox.
|
||||
if (alwaysApprovedCommands.has(key)) {
|
||||
return execCommand(
|
||||
args,
|
||||
/* applyPatch */ undefined,
|
||||
/* runInSandbox */ false,
|
||||
abortSignal,
|
||||
).then(convertSummaryToResult);
|
||||
}
|
||||
|
||||
// 2) Otherwise fall back to the normal policy
|
||||
// `canAutoApprove` now requires the list of writable roots that the command
|
||||
// is allowed to modify. For the CLI we conservatively pass the current
|
||||
// working directory so that edits are constrained to the project root. If
|
||||
// the caller wishes to broaden or restrict the set it can be made
|
||||
// configurable in the future.
|
||||
const safety = canAutoApprove(command, policy, [process.cwd()]);
|
||||
|
||||
let runInSandbox: boolean;
|
||||
switch (safety.type) {
|
||||
case "ask-user": {
|
||||
const review = await askUserPermission(
|
||||
args,
|
||||
safety.applyPatch,
|
||||
getCommandConfirmation,
|
||||
);
|
||||
if (review != null) {
|
||||
return review;
|
||||
}
|
||||
|
||||
runInSandbox = false;
|
||||
break;
|
||||
}
|
||||
case "auto-approve": {
|
||||
runInSandbox = safety.runInSandbox;
|
||||
break;
|
||||
}
|
||||
case "reject": {
|
||||
return {
|
||||
outputText: "aborted",
|
||||
metadata: {
|
||||
error: "command rejected",
|
||||
reason: "Command rejected by auto-approval system.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { applyPatch } = safety;
|
||||
const summary = await execCommand(
|
||||
args,
|
||||
applyPatch,
|
||||
runInSandbox,
|
||||
abortSignal,
|
||||
);
|
||||
// If the operation was aborted in the meantime, propagate the cancellation
|
||||
// upward by returning an empty (no‑op) result so that the agent loop will
|
||||
// exit cleanly without emitting spurious output.
|
||||
if (abortSignal?.aborted) {
|
||||
return {
|
||||
outputText: "",
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
if (
|
||||
summary.exitCode !== 0 &&
|
||||
runInSandbox &&
|
||||
// Default: If the user has configured to ignore and continue,
|
||||
// skip re-running the command.
|
||||
//
|
||||
// Otherwise, if they selected "ask-user", then we should ask the user
|
||||
// for permission to re-run the command outside of the sandbox.
|
||||
config.fullAutoErrorMode &&
|
||||
config.fullAutoErrorMode === FullAutoErrorMode.ASK_USER
|
||||
) {
|
||||
const review = await askUserPermission(
|
||||
args,
|
||||
safety.applyPatch,
|
||||
getCommandConfirmation,
|
||||
);
|
||||
if (review != null) {
|
||||
return review;
|
||||
} else {
|
||||
// The user has approved the command, so we will run it outside of the
|
||||
// sandbox.
|
||||
const summary = await execCommand(args, applyPatch, false, abortSignal);
|
||||
return convertSummaryToResult(summary);
|
||||
}
|
||||
} else {
|
||||
return convertSummaryToResult(summary);
|
||||
}
|
||||
}
|
||||
|
||||
function convertSummaryToResult(
|
||||
summary: ExecCommandSummary,
|
||||
): HandleExecCommandResult {
|
||||
const { stdout, stderr, exitCode, durationMs } = summary;
|
||||
return {
|
||||
outputText: stdout || stderr,
|
||||
metadata: {
|
||||
exit_code: exitCode,
|
||||
duration_seconds: Math.round(durationMs / 100) / 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ExecCommandSummary = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
async function execCommand(
|
||||
execInput: ExecInput,
|
||||
applyPatchCommand: ApplyPatchCommand | undefined,
|
||||
runInSandbox: boolean,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecCommandSummary> {
|
||||
if (isLoggingEnabled()) {
|
||||
if (applyPatchCommand != null) {
|
||||
log("EXEC running apply_patch command");
|
||||
} else {
|
||||
const { cmd, workdir, timeoutInMillis } = execInput;
|
||||
// Seconds are a bit easier to read in log messages and most timeouts
|
||||
// are specified as multiples of 1000, anyway.
|
||||
const timeout =
|
||||
timeoutInMillis != null
|
||||
? Math.round(timeoutInMillis / 1000).toString()
|
||||
: "undefined";
|
||||
log(
|
||||
`EXEC running \`${formatCommandForDisplay(
|
||||
cmd,
|
||||
)}\` in workdir=${workdir} with timeout=${timeout}s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note execApplyPatch() and exec() are coded defensively and should not
|
||||
// throw. Any internal errors should be mapped to a non-zero value for the
|
||||
// exitCode field.
|
||||
const start = Date.now();
|
||||
const execResult =
|
||||
applyPatchCommand != null
|
||||
? execApplyPatch(applyPatchCommand.patch)
|
||||
: await exec(execInput, await getSandbox(runInSandbox), abortSignal);
|
||||
const duration = Date.now() - start;
|
||||
const { stdout, stderr, exitCode } = execResult;
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`EXEC exit=${exitCode} time=${duration}ms:\n\tSTDOUT: ${stdout}\n\tSTDERR: ${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
durationMs: duration,
|
||||
};
|
||||
}
|
||||
|
||||
const isInContainer = async (): Promise<boolean> => {
|
||||
try {
|
||||
await access("/proc/1/cgroup");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
|
||||
if (runInSandbox) {
|
||||
if (process.platform === "darwin") {
|
||||
return SandboxType.MACOS_SEATBELT;
|
||||
} else if (await isInContainer()) {
|
||||
return SandboxType.NONE;
|
||||
}
|
||||
throw new Error("Sandbox was mandated, but no sandbox is available!");
|
||||
} else {
|
||||
return SandboxType.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If return value is non-null, then the command was rejected by the user.
|
||||
*/
|
||||
async function askUserPermission(
|
||||
args: ExecInput,
|
||||
applyPatchCommand: ApplyPatchCommand | undefined,
|
||||
getCommandConfirmation: (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
) => Promise<CommandConfirmation>,
|
||||
): Promise<HandleExecCommandResult | null> {
|
||||
const { review: decision, customDenyMessage } = await getCommandConfirmation(
|
||||
args.cmd,
|
||||
applyPatchCommand,
|
||||
);
|
||||
|
||||
if (decision === ReviewDecision.ALWAYS) {
|
||||
// Persist this command so we won't ask again during this session.
|
||||
const key = deriveCommandKey(args.cmd);
|
||||
alwaysApprovedCommands.add(key);
|
||||
}
|
||||
|
||||
// Any decision other than an affirmative (YES / ALWAYS) aborts execution.
|
||||
if (decision !== ReviewDecision.YES && decision !== ReviewDecision.ALWAYS) {
|
||||
const note =
|
||||
decision === ReviewDecision.NO_CONTINUE
|
||||
? customDenyMessage?.trim() || "No, don't do that — keep going though."
|
||||
: "No, don't do that — stop for now.";
|
||||
return {
|
||||
outputText: "aborted",
|
||||
metadata: {},
|
||||
additionalItems: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: note }],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
129
codex-cli/src/utils/agent/log.ts
Normal file
129
codex-cli/src/utils/agent/log.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import * as fsSync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
interface Logger {
|
||||
/** Checking this can be used to avoid constructing a large log message. */
|
||||
isLoggingEnabled(): boolean;
|
||||
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
class AsyncLogger implements Logger {
|
||||
private queue: Array<string> = [];
|
||||
private isWriting: boolean = false;
|
||||
|
||||
constructor(private filePath: string) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
isLoggingEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
log(message: string): void {
|
||||
const entry = `[${now()}] ${message}\n`;
|
||||
this.queue.push(entry);
|
||||
this.maybeWrite();
|
||||
}
|
||||
|
||||
private async maybeWrite(): Promise<void> {
|
||||
if (this.isWriting || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWriting = true;
|
||||
const messages = this.queue.join("");
|
||||
this.queue = [];
|
||||
|
||||
try {
|
||||
await fs.appendFile(this.filePath, messages);
|
||||
} finally {
|
||||
this.isWriting = false;
|
||||
}
|
||||
|
||||
this.maybeWrite();
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyLogger implements Logger {
|
||||
isLoggingEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
log(_message: string): void {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
function now() {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
/**
|
||||
* Creates a .log file for this session, but also symlinks codex-cli-latest.log
|
||||
* to the current log file so you can reliably run:
|
||||
*
|
||||
* - Mac/Windows: `tail -F "$TMPDIR/oai-codex/codex-cli-latest.log"`
|
||||
* - Linux: `tail -F ~/.local/oai-codex/codex-cli-latest.log`
|
||||
*/
|
||||
export function initLogger(): Logger {
|
||||
if (logger) {
|
||||
return logger;
|
||||
} else if (!process.env["DEBUG"]) {
|
||||
logger = new EmptyLogger();
|
||||
return logger;
|
||||
}
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
const isWin = process.platform === "win32";
|
||||
|
||||
// On Mac and Windows, os.tmpdir() returns a user-specifc folder, so prefer
|
||||
// it there. On Linux, use ~/.local/oai-codex so logs are not world-readable.
|
||||
const logDir =
|
||||
isMac || isWin
|
||||
? path.join(os.tmpdir(), "oai-codex")
|
||||
: path.join(os.homedir(), ".local", "oai-codex");
|
||||
fsSync.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = path.join(logDir, `codex-cli-${now()}.log`);
|
||||
// Write the empty string so the file exists and can be tail'd.
|
||||
fsSync.writeFileSync(logFile, "");
|
||||
|
||||
// Symlink to codex-cli-latest.log on UNIX because Windows is funny about
|
||||
// symlinks.
|
||||
if (!isWin) {
|
||||
const latestLink = path.join(logDir, "codex-cli-latest.log");
|
||||
try {
|
||||
fsSync.symlinkSync(logFile, latestLink, "file");
|
||||
} catch (err: unknown) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === "EEXIST") {
|
||||
fsSync.unlinkSync(latestLink);
|
||||
fsSync.symlinkSync(logFile, latestLink, "file");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger = new AsyncLogger(logFile);
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function log(message: string): void {
|
||||
(logger ?? initLogger()).log(message);
|
||||
}
|
||||
|
||||
export function isLoggingEnabled(): boolean {
|
||||
return (logger ?? initLogger()).isLoggingEnabled();
|
||||
}
|
||||
112
codex-cli/src/utils/agent/parse-apply-patch.ts
Normal file
112
codex-cli/src/utils/agent/parse-apply-patch.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type ApplyPatchCreateFileOp = {
|
||||
type: "create";
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchDeleteFileOp = {
|
||||
type: "delete";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchUpdateFileOp = {
|
||||
type: "update";
|
||||
path: string;
|
||||
update: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
};
|
||||
|
||||
export type ApplyPatchOp =
|
||||
| ApplyPatchCreateFileOp
|
||||
| ApplyPatchDeleteFileOp
|
||||
| ApplyPatchUpdateFileOp;
|
||||
|
||||
const PATCH_PREFIX = "*** Begin Patch\n";
|
||||
const PATCH_SUFFIX = "\n*** End Patch";
|
||||
const ADD_FILE_PREFIX = "*** Add File: ";
|
||||
const DELETE_FILE_PREFIX = "*** Delete File: ";
|
||||
const UPDATE_FILE_PREFIX = "*** Update File: ";
|
||||
const END_OF_FILE_PREFIX = "*** End of File";
|
||||
const HUNK_ADD_LINE_PREFIX = "+";
|
||||
|
||||
/**
|
||||
* @returns null when the patch is invalid
|
||||
*/
|
||||
export function parseApplyPatch(patch: string): Array<ApplyPatchOp> | null {
|
||||
if (!patch.startsWith(PATCH_PREFIX)) {
|
||||
// Patch must begin with '*** Begin Patch'
|
||||
return null;
|
||||
} else if (!patch.endsWith(PATCH_SUFFIX)) {
|
||||
// Patch must end with '*** End Patch'
|
||||
return null;
|
||||
}
|
||||
|
||||
const patchBody = patch.slice(
|
||||
PATCH_PREFIX.length,
|
||||
patch.length - PATCH_SUFFIX.length,
|
||||
);
|
||||
|
||||
const lines = patchBody.split("\n");
|
||||
|
||||
const ops: Array<ApplyPatchOp> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(END_OF_FILE_PREFIX)) {
|
||||
continue;
|
||||
} else if (line.startsWith(ADD_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "create",
|
||||
path: line.slice(ADD_FILE_PREFIX.length).trim(),
|
||||
content: "",
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(DELETE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "delete",
|
||||
path: line.slice(DELETE_FILE_PREFIX.length).trim(),
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(UPDATE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "update",
|
||||
path: line.slice(UPDATE_FILE_PREFIX.length).trim(),
|
||||
update: "",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastOp = ops[ops.length - 1];
|
||||
|
||||
if (lastOp?.type === "create") {
|
||||
lastOp.content = appendLine(
|
||||
lastOp.content,
|
||||
line.slice(HUNK_ADD_LINE_PREFIX.length),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastOp?.type !== "update") {
|
||||
// Expected update op but got ${lastOp?.type} for line ${line}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith(HUNK_ADD_LINE_PREFIX)) {
|
||||
lastOp.added += 1;
|
||||
} else if (line.startsWith("-")) {
|
||||
lastOp.deleted += 1;
|
||||
}
|
||||
lastOp.update += lastOp.update ? "\n" + line : line;
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
function appendLine(content: string, line: string) {
|
||||
if (!content.length) {
|
||||
return line;
|
||||
}
|
||||
return [content, line].join("\n");
|
||||
}
|
||||
18
codex-cli/src/utils/agent/review.ts
Normal file
18
codex-cli/src/utils/agent/review.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { SafeCommandReason } from "@lib/approvals";
|
||||
|
||||
export type CommandReviewDetails = {
|
||||
cmd: Array<string>;
|
||||
cmdReadableText: string;
|
||||
autoApproval: SafeCommandReason | null;
|
||||
};
|
||||
|
||||
export enum ReviewDecision {
|
||||
YES = "yes",
|
||||
NO_CONTINUE = "no-continue",
|
||||
NO_EXIT = "no-exit",
|
||||
/**
|
||||
* User has approved this command and wants to automatically approve any
|
||||
* future identical instances for the remainder of the session.
|
||||
*/
|
||||
ALWAYS = "always",
|
||||
}
|
||||
30
codex-cli/src/utils/agent/sandbox/interface.ts
Normal file
30
codex-cli/src/utils/agent/sandbox/interface.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export enum SandboxType {
|
||||
NONE = "none",
|
||||
MACOS_SEATBELT = "macos.seatbelt",
|
||||
LINUX_LANDLOCK = "linux.landlock",
|
||||
}
|
||||
|
||||
export type ExecInput = {
|
||||
cmd: Array<string>;
|
||||
workdir: string | undefined;
|
||||
timeoutInMillis: number | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of executing a command. Caller is responsible for checking `code` to
|
||||
* determine whether the command was successful.
|
||||
*/
|
||||
export type ExecResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Value to use with the `metadata` field of a `ResponseItem` whose type is
|
||||
* `function_call_output`.
|
||||
*/
|
||||
export type ExecOutputMetadata = {
|
||||
exit_code: number;
|
||||
duration_seconds: number;
|
||||
};
|
||||
141
codex-cli/src/utils/agent/sandbox/macos-seatbelt.ts
Normal file
141
codex-cli/src/utils/agent/sandbox/macos-seatbelt.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ExecResult } from "./interface.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
import { exec } from "./raw-exec.js";
|
||||
import { log } from "../log.js";
|
||||
import { CONFIG_DIR } from "src/utils/config.js";
|
||||
|
||||
function getCommonRoots() {
|
||||
return [
|
||||
CONFIG_DIR,
|
||||
// Without this root, it'll cause:
|
||||
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
|
||||
`${process.env["HOME"]}/.pyenv`,
|
||||
];
|
||||
}
|
||||
|
||||
export function execWithSeatbelt(
|
||||
cmd: Array<string>,
|
||||
opts: SpawnOptions,
|
||||
writableRoots: Array<string>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
let scopedWritePolicy: string;
|
||||
let policyTemplateParams: Array<string>;
|
||||
if (writableRoots.length > 0) {
|
||||
// Add `~/.codex` to the list of writable roots
|
||||
// (if there's any already, not in read-only mode)
|
||||
getCommonRoots().map((root) => writableRoots.push(root));
|
||||
const { policies, params } = writableRoots
|
||||
.map((root, index) => ({
|
||||
policy: `(subpath (param "WRITABLE_ROOT_${index}"))`,
|
||||
param: `-DWRITABLE_ROOT_${index}=${root}`,
|
||||
}))
|
||||
.reduce(
|
||||
(
|
||||
acc: { policies: Array<string>; params: Array<string> },
|
||||
{ policy, param },
|
||||
) => {
|
||||
acc.policies.push(policy);
|
||||
acc.params.push(param);
|
||||
return acc;
|
||||
},
|
||||
{ policies: [], params: [] },
|
||||
);
|
||||
|
||||
scopedWritePolicy = `\n(allow file-write*\n${policies.join(" ")}\n)`;
|
||||
policyTemplateParams = params;
|
||||
} else {
|
||||
scopedWritePolicy = "";
|
||||
policyTemplateParams = [];
|
||||
}
|
||||
|
||||
const fullPolicy = READ_ONLY_SEATBELT_POLICY + scopedWritePolicy;
|
||||
log(
|
||||
`Running seatbelt with policy: ${fullPolicy} and ${
|
||||
policyTemplateParams.length
|
||||
} template params: ${policyTemplateParams.join(", ")}`,
|
||||
);
|
||||
|
||||
const fullCommand = [
|
||||
"sandbox-exec",
|
||||
"-p",
|
||||
fullPolicy,
|
||||
...policyTemplateParams,
|
||||
"--",
|
||||
...cmd,
|
||||
];
|
||||
return exec(fullCommand, opts, writableRoots, abortSignal);
|
||||
}
|
||||
|
||||
const READ_ONLY_SEATBELT_POLICY = `
|
||||
(version 1)
|
||||
|
||||
; inspired by Chrome's sandbox policy:
|
||||
; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
|
||||
; start with closed-by-default
|
||||
(deny default)
|
||||
|
||||
; allow read-only file operations
|
||||
(allow file-read*)
|
||||
|
||||
; child processes inherit the policy of their parent
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
|
||||
(allow file-write-data
|
||||
(require-all
|
||||
(path "/dev/null")
|
||||
(vnode-type CHARACTER-DEVICE)))
|
||||
|
||||
; sysctls permitted.
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.memsize")
|
||||
(sysctl-name "hw.pagesize")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)`.trim();
|
||||
199
codex-cli/src/utils/agent/sandbox/raw-exec.ts
Normal file
199
codex-cli/src/utils/agent/sandbox/raw-exec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { ExecResult } from "./interface";
|
||||
import type {
|
||||
ChildProcess,
|
||||
SpawnOptions,
|
||||
SpawnOptionsWithStdioTuple,
|
||||
StdioNull,
|
||||
StdioPipe,
|
||||
} from "child_process";
|
||||
|
||||
import { log, isLoggingEnabled } from "../log.js";
|
||||
import { spawn } from "child_process";
|
||||
import * as os from "os";
|
||||
|
||||
const MAX_BUFFER = 1024 * 100; // 100 KB
|
||||
|
||||
/**
|
||||
* This function should never return a rejected promise: errors should be
|
||||
* mapped to a non-zero exit code and the error message should be in stderr.
|
||||
*/
|
||||
export function exec(
|
||||
command: Array<string>,
|
||||
options: SpawnOptions,
|
||||
_writableRoots: Array<string>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
const prog = command[0];
|
||||
if (typeof prog !== "string") {
|
||||
return Promise.resolve({
|
||||
stdout: "",
|
||||
stderr: "command[0] is not a string",
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// We use spawn() instead of exec() or execFile() so that we can set the
|
||||
// stdio options to "ignore" for stdin. Ripgrep has a heuristic where it
|
||||
// may try to read from stdin as explained here:
|
||||
//
|
||||
// https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103
|
||||
//
|
||||
// This can be a problem because if you save the following to a file and
|
||||
// run it with `node`, it will hang forever:
|
||||
//
|
||||
// ```
|
||||
// const {execFile} = require('child_process');
|
||||
//
|
||||
// execFile('rg', ['foo'], (error, stdout, stderr) => {
|
||||
// if (error) {
|
||||
// console.error(`error: ${error}n\nstderr: ${stderr}`);
|
||||
// } else {
|
||||
// console.log(`stdout: ${stdout}`);
|
||||
// }
|
||||
// });
|
||||
// ```
|
||||
//
|
||||
// Even if you pass `{stdio: ["ignore", "pipe", "pipe"] }` to execFile(), the
|
||||
// hang still happens as the `stdio` is seemingly ignored. Using spawn()
|
||||
// works around this issue.
|
||||
const fullOptions: SpawnOptionsWithStdioTuple<
|
||||
StdioNull,
|
||||
StdioPipe,
|
||||
StdioPipe
|
||||
> = {
|
||||
...options,
|
||||
// Inherit any caller‑supplied stdio flags but force stdin to "ignore" so
|
||||
// the child never attempts to read from us (see lengthy comment above).
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Launch the child in its *own* process group so that we can later send a
|
||||
// single signal to the entire group – this reliably terminates not only
|
||||
// the immediate child but also any grandchildren it might have spawned
|
||||
// (think `bash -c "sleep 999"`).
|
||||
detached: true,
|
||||
};
|
||||
|
||||
const child: ChildProcess = spawn(prog, command.slice(1), fullOptions);
|
||||
// If an AbortSignal is provided, ensure the spawned process is terminated
|
||||
// when the signal is triggered so that cancellations propagate down to any
|
||||
// long‑running child processes. We default to SIGTERM to give the process a
|
||||
// chance to clean up, falling back to SIGKILL if it does not exit in a
|
||||
// timely fashion.
|
||||
if (abortSignal) {
|
||||
const abortHandler = () => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(`raw-exec: abort signal received – killing child ${child.pid}`);
|
||||
}
|
||||
const killTarget = (signal: NodeJS.Signals) => {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
// Send to the *process group* so grandchildren are included.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
// Fallback: kill only the immediate child (may leave orphans on
|
||||
// exotic kernels that lack process‑group semantics, but better
|
||||
// than nothing).
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
// First try graceful termination.
|
||||
killTarget("SIGTERM");
|
||||
|
||||
// Escalate to SIGKILL if the group refuses to die.
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
killTarget("SIGKILL");
|
||||
}
|
||||
}, 2000).unref();
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
abortHandler();
|
||||
} else {
|
||||
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
}
|
||||
if (!child.pid) {
|
||||
return Promise.resolve({
|
||||
stdout: "",
|
||||
stderr: `likely failed because ${prog} could not be found`,
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const stdoutChunks: Array<Buffer> = [];
|
||||
const stderrChunks: Array<Buffer> = [];
|
||||
let numStdoutBytes = 0;
|
||||
let numStderrBytes = 0;
|
||||
let hitMaxStdout = false;
|
||||
let hitMaxStderr = false;
|
||||
|
||||
return new Promise<ExecResult>((resolve) => {
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
if (!hitMaxStdout) {
|
||||
numStdoutBytes += data.length;
|
||||
if (numStdoutBytes <= MAX_BUFFER) {
|
||||
stdoutChunks.push(data);
|
||||
} else {
|
||||
hitMaxStdout = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
if (!hitMaxStderr) {
|
||||
numStderrBytes += data.length;
|
||||
if (numStderrBytes <= MAX_BUFFER) {
|
||||
stderrChunks.push(data);
|
||||
} else {
|
||||
hitMaxStderr = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
|
||||
// Map (code, signal) to an exit code. We expect exactly one of the two
|
||||
// values to be non-null, but we code defensively to handle the case where
|
||||
// both are null.
|
||||
let exitCode: number;
|
||||
if (code != null) {
|
||||
exitCode = code;
|
||||
} else if (signal != null && signal in os.constants.signals) {
|
||||
const signalNum =
|
||||
os.constants.signals[signal as keyof typeof os.constants.signals];
|
||||
exitCode = 128 + signalNum;
|
||||
} else {
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`raw-exec: child ${child.pid} exited code=${exitCode} signal=${signal}`,
|
||||
);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
resolve({
|
||||
stdout: "",
|
||||
stderr: String(err),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
51
codex-cli/src/utils/approximate-tokens-used.ts
Normal file
51
codex-cli/src/utils/approximate-tokens-used.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
/**
|
||||
* Roughly estimate the number of language‑model tokens represented by a list
|
||||
* of OpenAI `ResponseItem`s.
|
||||
*
|
||||
* A full tokenizer would be more accurate, but would add a heavyweight
|
||||
* dependency for only marginal benefit. Empirically, assuming ~4 characters
|
||||
* per token offers a good enough signal for displaying context‑window usage
|
||||
* to the user.
|
||||
*
|
||||
* The algorithm counts characters from the different content types we may
|
||||
* encounter and then converts that char count to tokens by dividing by four
|
||||
* and rounding up.
|
||||
*/
|
||||
export function approximateTokensUsed(items: Array<ResponseItem>): number {
|
||||
let charCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case "message": {
|
||||
for (const c of item.content) {
|
||||
if (c.type === "input_text" || c.type === "output_text") {
|
||||
charCount += c.text.length;
|
||||
} else if (c.type === "refusal") {
|
||||
charCount += c.refusal.length;
|
||||
} else if (c.type === "input_file") {
|
||||
charCount += c.filename?.length ?? 0;
|
||||
}
|
||||
// images and other content types are ignored (0 chars)
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "function_call": {
|
||||
charCount += (item.name?.length || 0) + (item.arguments?.length || 0);
|
||||
break;
|
||||
}
|
||||
|
||||
case "function_call_output": {
|
||||
charCount += item.output.length;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.ceil(charCount / 4);
|
||||
}
|
||||
9
codex-cli/src/utils/auto-approval-mode.js
Normal file
9
codex-cli/src/utils/auto-approval-mode.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// This tiny shim exists solely so that development tooling such as `ts-node`
|
||||
// (which executes the *source* files directly) can resolve the existing
|
||||
// `./auto-approval-mode.js` import specifier used throughout the code‑base.
|
||||
//
|
||||
// In the emitted JavaScript (built via `tsc --module nodenext`) the compiler
|
||||
// rewrites the path to point at the generated `.js` file automatically, so
|
||||
// having this shim in the source tree is completely transparent for
|
||||
// production builds.
|
||||
export { AutoApprovalMode, FullAutoErrorMode } from "./auto-approval-mode.ts";
|
||||
10
codex-cli/src/utils/auto-approval-mode.ts
Normal file
10
codex-cli/src/utils/auto-approval-mode.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum AutoApprovalMode {
|
||||
SUGGEST = "suggest",
|
||||
AUTO_EDIT = "auto-edit",
|
||||
FULL_AUTO = "full-auto",
|
||||
}
|
||||
|
||||
export enum FullAutoErrorMode {
|
||||
ASK_USER = "ask-user",
|
||||
IGNORE_AND_CONTINUE = "ignore-and-continue",
|
||||
}
|
||||
31
codex-cli/src/utils/check-in-git.ts
Normal file
31
codex-cli/src/utils/check-in-git.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
/**
|
||||
* Returns true if the given directory is part of a Git repository.
|
||||
*
|
||||
* This uses the canonical Git command `git rev-parse --is-inside-work-tree`
|
||||
* which exits with status 0 when executed anywhere inside a working tree
|
||||
* (including the repo root) and exits with a non‑zero status otherwise. We
|
||||
* intentionally ignore stdout/stderr and only rely on the exit code so that
|
||||
* this works consistently across Git versions and configurations.
|
||||
*
|
||||
* The function is fully synchronous because it is typically used during CLI
|
||||
* startup (e.g. to decide whether to enable certain Git‑specific features) and
|
||||
* a synchronous check keeps such call‑sites simple. The command is extremely
|
||||
* fast (~1ms) so blocking the event‑loop briefly is acceptable.
|
||||
*/
|
||||
export function checkInGit(workdir: string): boolean {
|
||||
try {
|
||||
// "git rev-parse --is-inside-work-tree" prints either "true" or "false" to
|
||||
// stdout. We don't care about the output — only the exit status — so we
|
||||
// discard stdio for maximum performance and to avoid leaking noise if the
|
||||
// caller happens to inherit stdio.
|
||||
execSync("git rev-parse --is-inside-work-tree", {
|
||||
cwd: workdir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
356
codex-cli/src/utils/config.ts
Normal file
356
codex-cli/src/utils/config.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
// NOTE: We intentionally point the TypeScript import at the source file
|
||||
// (`./auto-approval-mode.ts`) instead of the emitted `.js` bundle. This makes
|
||||
// the module resolvable when the project is executed via `ts-node`, which
|
||||
// resolves *source* paths rather than built artefacts. During a production
|
||||
// build the TypeScript compiler will automatically rewrite the path to
|
||||
// `./auto-approval-mode.js`, so the change is completely transparent for the
|
||||
// compiled `dist/` output used by the published CLI.
|
||||
|
||||
import type { FullAutoErrorMode } from "./auto-approval-mode.js";
|
||||
|
||||
import { log, isLoggingEnabled } from "./agent/log.js";
|
||||
import { AutoApprovalMode } from "./auto-approval-mode.js";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, extname, resolve as resolvePath } from "path";
|
||||
|
||||
export const DEFAULT_AGENTIC_MODEL = "o4-mini";
|
||||
export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1";
|
||||
export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST;
|
||||
export const DEFAULT_INSTRUCTIONS = "";
|
||||
|
||||
export const CONFIG_DIR = join(homedir(), ".codex");
|
||||
export const CONFIG_JSON_FILEPATH = join(CONFIG_DIR, "config.json");
|
||||
export const CONFIG_YAML_FILEPATH = join(CONFIG_DIR, "config.yaml");
|
||||
export const CONFIG_YML_FILEPATH = join(CONFIG_DIR, "config.yml");
|
||||
|
||||
// Keep the original constant name for backward compatibility, but point it at
|
||||
// the default JSON path. Code that relies on this constant will continue to
|
||||
// work unchanged.
|
||||
export const CONFIG_FILEPATH = CONFIG_JSON_FILEPATH;
|
||||
export const INSTRUCTIONS_FILEPATH = join(CONFIG_DIR, "instructions.md");
|
||||
|
||||
export const OPENAI_TIMEOUT_MS =
|
||||
parseInt(process.env["OPENAI_TIMEOUT_MS"] || "0", 10) || undefined;
|
||||
export const OPENAI_BASE_URL = process.env["OPENAI_BASE_URL"] || "";
|
||||
export let OPENAI_API_KEY = process.env["OPENAI_API_KEY"] || "";
|
||||
|
||||
export function setApiKey(apiKey: string): void {
|
||||
OPENAI_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
// Formatting (quiet mode-only).
|
||||
export const PRETTY_PRINT = Boolean(process.env["PRETTY_PRINT"] || "");
|
||||
|
||||
// Represents config as persisted in config.json.
|
||||
export type StoredConfig = {
|
||||
model?: string;
|
||||
approvalMode?: AutoApprovalMode;
|
||||
fullAutoErrorMode?: FullAutoErrorMode;
|
||||
memory?: MemoryConfig;
|
||||
};
|
||||
|
||||
// Minimal config written on first run. An *empty* model string ensures that
|
||||
// we always fall back to DEFAULT_MODEL on load, so updates to the default keep
|
||||
// propagating to existing users until they explicitly set a model.
|
||||
export const EMPTY_STORED_CONFIG: StoredConfig = { model: "" };
|
||||
|
||||
// Pre‑stringified JSON variant so we don’t stringify repeatedly.
|
||||
const EMPTY_CONFIG_JSON = JSON.stringify(EMPTY_STORED_CONFIG, null, 2) + "\n";
|
||||
|
||||
export type MemoryConfig = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
// Represents full runtime config, including loaded instructions
|
||||
export type AppConfig = {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
instructions: string;
|
||||
fullAutoErrorMode?: FullAutoErrorMode;
|
||||
memory?: MemoryConfig;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project doc support (codex.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PROJECT_DOC_MAX_BYTES = 32 * 1024; // 32 kB
|
||||
|
||||
const PROJECT_DOC_FILENAMES = ["codex.md", ".codex.md", "CODEX.md"];
|
||||
|
||||
export function discoverProjectDocPath(startDir: string): string | null {
|
||||
const cwd = resolvePath(startDir);
|
||||
|
||||
// 1) Look in the explicit CWD first:
|
||||
for (const name of PROJECT_DOC_FILENAMES) {
|
||||
const direct = join(cwd, name);
|
||||
if (existsSync(direct)) {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: walk up to the Git root and look there
|
||||
let dir = cwd;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const gitPath = join(dir, ".git");
|
||||
if (existsSync(gitPath)) {
|
||||
// Once we hit the Git root, search its top‑level for the doc
|
||||
for (const name of PROJECT_DOC_FILENAMES) {
|
||||
const candidate = join(dir, name);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// If Git root but no doc, stop looking
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
// Reached filesystem root without finding Git
|
||||
return null;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the project documentation markdown (codex.md) if present. If the file
|
||||
* exceeds {@link PROJECT_DOC_MAX_BYTES} it will be truncated and a warning is
|
||||
* logged.
|
||||
*
|
||||
* @param cwd The current working directory of the caller
|
||||
* @param explicitPath If provided, skips discovery and loads the given path
|
||||
*/
|
||||
export function loadProjectDoc(cwd: string, explicitPath?: string): string {
|
||||
let filepath: string | null = null;
|
||||
|
||||
if (explicitPath) {
|
||||
filepath = resolvePath(cwd, explicitPath);
|
||||
if (!existsSync(filepath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`codex: project doc not found at ${filepath}`);
|
||||
filepath = null;
|
||||
}
|
||||
} else {
|
||||
filepath = discoverProjectDocPath(cwd);
|
||||
}
|
||||
|
||||
if (!filepath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = readFileSync(filepath);
|
||||
if (buf.byteLength > PROJECT_DOC_MAX_BYTES) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`codex: project doc '${filepath}' exceeds ${PROJECT_DOC_MAX_BYTES} bytes – truncating.`,
|
||||
);
|
||||
}
|
||||
return buf.slice(0, PROJECT_DOC_MAX_BYTES).toString("utf-8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// (Receives params for testing)
|
||||
export type LoadConfigOptions = {
|
||||
/** Working directory used for project doc discovery */
|
||||
cwd?: string;
|
||||
/** Disable inclusion of the project doc */
|
||||
disableProjectDoc?: boolean;
|
||||
/** Explicit path to project doc (overrides discovery) */
|
||||
projectDocPath?: string;
|
||||
/** Whether we are in fullcontext mode. */
|
||||
isFullContext?: boolean;
|
||||
};
|
||||
|
||||
export const loadConfig = (
|
||||
configPath: string | undefined = CONFIG_FILEPATH,
|
||||
instructionsPath: string | undefined = INSTRUCTIONS_FILEPATH,
|
||||
options: LoadConfigOptions = {},
|
||||
): AppConfig => {
|
||||
// Determine the actual path to load. If the provided path doesn't exist and
|
||||
// the caller passed the default JSON path, automatically fall back to YAML
|
||||
// variants.
|
||||
let actualConfigPath = configPath;
|
||||
if (!existsSync(actualConfigPath)) {
|
||||
if (configPath === CONFIG_FILEPATH) {
|
||||
if (existsSync(CONFIG_YAML_FILEPATH)) {
|
||||
actualConfigPath = CONFIG_YAML_FILEPATH;
|
||||
} else if (existsSync(CONFIG_YML_FILEPATH)) {
|
||||
actualConfigPath = CONFIG_YML_FILEPATH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let storedConfig: StoredConfig = {};
|
||||
if (existsSync(actualConfigPath)) {
|
||||
const raw = readFileSync(actualConfigPath, "utf-8");
|
||||
const ext = extname(actualConfigPath).toLowerCase();
|
||||
try {
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
storedConfig = loadYaml(raw) as unknown as StoredConfig;
|
||||
} else {
|
||||
storedConfig = JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall back to empty config to avoid crashing.
|
||||
storedConfig = {};
|
||||
}
|
||||
}
|
||||
|
||||
const instructionsFilePathResolved =
|
||||
instructionsPath ?? INSTRUCTIONS_FILEPATH;
|
||||
const userInstructions = existsSync(instructionsFilePathResolved)
|
||||
? readFileSync(instructionsFilePathResolved, "utf-8")
|
||||
: DEFAULT_INSTRUCTIONS;
|
||||
|
||||
// Project doc -----------------------------------------------------------
|
||||
const shouldLoadProjectDoc =
|
||||
!options.disableProjectDoc &&
|
||||
process.env["CODEX_DISABLE_PROJECT_DOC"] !== "1";
|
||||
|
||||
let projectDoc = "";
|
||||
let projectDocPath: string | null = null;
|
||||
if (shouldLoadProjectDoc) {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
projectDoc = loadProjectDoc(cwd, options.projectDocPath);
|
||||
projectDocPath = options.projectDocPath
|
||||
? resolvePath(cwd, options.projectDocPath)
|
||||
: discoverProjectDocPath(cwd);
|
||||
if (projectDocPath) {
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`[codex] Loaded project doc from ${projectDocPath} (${projectDoc.length} bytes)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log(`[codex] No project doc found in ${cwd}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combinedInstructions = [userInstructions, projectDoc]
|
||||
.filter((s) => s && s.trim() !== "")
|
||||
.join("\n\n--- project-doc ---\n\n");
|
||||
|
||||
// Treat empty string ("" or whitespace) as absence so we can fall back to
|
||||
// the latest DEFAULT_MODEL.
|
||||
const storedModel =
|
||||
storedConfig.model && storedConfig.model.trim() !== ""
|
||||
? storedConfig.model.trim()
|
||||
: undefined;
|
||||
|
||||
const config: AppConfig = {
|
||||
model:
|
||||
storedModel ??
|
||||
(options.isFullContext
|
||||
? DEFAULT_FULL_CONTEXT_MODEL
|
||||
: DEFAULT_AGENTIC_MODEL),
|
||||
instructions: combinedInstructions,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// First‑run bootstrap: if the configuration file (and/or its containing
|
||||
// directory) didn't exist we create them now so that users end up with a
|
||||
// materialised ~/.codex/config.json file on first execution. This mirrors
|
||||
// what `saveConfig()` would do but without requiring callers to remember to
|
||||
// invoke it separately.
|
||||
//
|
||||
// We intentionally perform this *after* we have computed the final
|
||||
// `config` object so that we can just persist the resolved defaults. The
|
||||
// write operations are guarded by `existsSync` checks so that subsequent
|
||||
// runs that already have a config will remain read‑only here.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
try {
|
||||
if (!existsSync(actualConfigPath)) {
|
||||
// Ensure the directory exists first.
|
||||
const dir = dirname(actualConfigPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Persist a minimal config – we include the `model` key but leave it as
|
||||
// an empty string so that `loadConfig()` treats it as "unset" and falls
|
||||
// back to whatever DEFAULT_MODEL is current at runtime. This prevents
|
||||
// pinning users to an old default after upgrading Codex.
|
||||
const ext = extname(actualConfigPath).toLowerCase();
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
writeFileSync(actualConfigPath, dumpYaml(EMPTY_STORED_CONFIG), "utf-8");
|
||||
} else {
|
||||
writeFileSync(actualConfigPath, EMPTY_CONFIG_JSON, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure the instructions file exists so users can edit it.
|
||||
if (!existsSync(instructionsFilePathResolved)) {
|
||||
const instrDir = dirname(instructionsFilePathResolved);
|
||||
if (!existsSync(instrDir)) {
|
||||
mkdirSync(instrDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(instructionsFilePathResolved, userInstructions, "utf-8");
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore any errors – failure to persist the defaults shouldn't
|
||||
// block the CLI from starting. A future explicit `codex config` command
|
||||
// or `saveConfig()` call can handle (re‑)writing later.
|
||||
}
|
||||
|
||||
// Only include the "memory" key if it was explicitly set by the user. This
|
||||
// preserves backward‑compatibility with older config files (and our test
|
||||
// fixtures) that don't include a "memory" section.
|
||||
if (storedConfig.memory !== undefined) {
|
||||
config.memory = storedConfig.memory;
|
||||
}
|
||||
|
||||
if (storedConfig.fullAutoErrorMode) {
|
||||
config.fullAutoErrorMode = storedConfig.fullAutoErrorMode;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const saveConfig = (
|
||||
config: AppConfig,
|
||||
configPath = CONFIG_FILEPATH,
|
||||
instructionsPath = INSTRUCTIONS_FILEPATH,
|
||||
): void => {
|
||||
// If the caller passed the default JSON path *and* a YAML config already
|
||||
// exists on disk, save back to that YAML file instead to preserve the
|
||||
// user's chosen format.
|
||||
let targetPath = configPath;
|
||||
if (
|
||||
configPath === CONFIG_FILEPATH &&
|
||||
!existsSync(configPath) &&
|
||||
(existsSync(CONFIG_YAML_FILEPATH) || existsSync(CONFIG_YML_FILEPATH))
|
||||
) {
|
||||
targetPath = existsSync(CONFIG_YAML_FILEPATH)
|
||||
? CONFIG_YAML_FILEPATH
|
||||
: CONFIG_YML_FILEPATH;
|
||||
}
|
||||
|
||||
const dir = dirname(targetPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = extname(targetPath).toLowerCase();
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
writeFileSync(targetPath, dumpYaml({ model: config.model }), "utf-8");
|
||||
} else {
|
||||
writeFileSync(
|
||||
targetPath,
|
||||
JSON.stringify({ model: config.model }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(instructionsPath, config.instructions, "utf-8");
|
||||
};
|
||||
31
codex-cli/src/utils/input-utils.ts
Normal file
31
codex-cli/src/utils/input-utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ResponseInputItem } from "openai/resources/responses/responses";
|
||||
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export async function createInputItem(
|
||||
text: string,
|
||||
images: Array<string>,
|
||||
): Promise<ResponseInputItem.Message> {
|
||||
const inputItem: ResponseInputItem.Message = {
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
type: "message",
|
||||
};
|
||||
|
||||
for (const filePath of images) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
const binary = await fs.readFile(filePath);
|
||||
const kind = await fileTypeFromBuffer(binary);
|
||||
/* eslint-enable no-await-in-loop */
|
||||
const encoded = binary.toString("base64");
|
||||
const mime = kind?.mime ?? "application/octet-stream";
|
||||
inputItem.content.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${mime};base64,${encoded}`,
|
||||
});
|
||||
}
|
||||
|
||||
return inputItem;
|
||||
}
|
||||
91
codex-cli/src/utils/model-utils.ts
Normal file
91
codex-cli/src/utils/model-utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { OPENAI_API_KEY } from "./config";
|
||||
import OpenAI from "openai";
|
||||
|
||||
export const RECOMMENDED_MODELS: Array<string> = ["o4-mini", "o3"];
|
||||
|
||||
/**
|
||||
* Background model loader / cache.
|
||||
*
|
||||
* We start fetching the list of available models from OpenAI once the CLI
|
||||
* enters interactive mode. The request is made exactly once during the
|
||||
* lifetime of the process and the results are cached for subsequent calls.
|
||||
*/
|
||||
|
||||
let modelsPromise: Promise<Array<string>> | null = null;
|
||||
|
||||
async function fetchModels(): Promise<Array<string>> {
|
||||
// If the user has not configured an API key we cannot hit the network
|
||||
if (!OPENAI_API_KEY) {
|
||||
return ["o4-mini"];
|
||||
}
|
||||
|
||||
try {
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
const list = await openai.models.list();
|
||||
|
||||
const models: Array<string> = [];
|
||||
for await (const model of list as AsyncIterable<{ id?: string }>) {
|
||||
if (model && typeof model.id === "string") {
|
||||
models.push(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
return models.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function preloadModels(): void {
|
||||
if (!modelsPromise) {
|
||||
// Fire‑and‑forget – callers that truly need the list should `await`
|
||||
// `getAvailableModels()` instead.
|
||||
void getAvailableModels();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableModels(): Promise<Array<string>> {
|
||||
if (!modelsPromise) {
|
||||
modelsPromise = fetchModels();
|
||||
}
|
||||
return modelsPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the provided model identifier is present in the set returned by
|
||||
* {@link getAvailableModels}. The list of models is fetched from the OpenAI
|
||||
* `/models` endpoint the first time it is required and then cached in‑process.
|
||||
*/
|
||||
export async function isModelSupportedForResponses(
|
||||
model: string | undefined | null,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
typeof model !== "string" ||
|
||||
model.trim() === "" ||
|
||||
RECOMMENDED_MODELS.includes(model)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const MODEL_LIST_TIMEOUT_MS = 2_000;
|
||||
|
||||
try {
|
||||
const models = await Promise.race<Array<string>>([
|
||||
getAvailableModels(),
|
||||
new Promise<Array<string>>((resolve) =>
|
||||
setTimeout(() => resolve([]), MODEL_LIST_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
// If the timeout fired we get an empty list → treat as supported to avoid
|
||||
// false negatives.
|
||||
if (models.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return models.includes(model.trim());
|
||||
} catch {
|
||||
// Network or library failure → don't block start‑up.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
240
codex-cli/src/utils/parsers.ts
Normal file
240
codex-cli/src/utils/parsers.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { CommandReviewDetails } from "./agent/review.js";
|
||||
import type {
|
||||
ExecInput,
|
||||
ExecOutputMetadata,
|
||||
} from "./agent/sandbox/interface.js";
|
||||
import type { SafeCommandReason } from "@lib/approvals.js";
|
||||
import type { ResponseFunctionToolCall } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { log } from "node:console";
|
||||
import process from "process";
|
||||
|
||||
// The console utility import is intentionally explicit to avoid bundlers from
|
||||
// including the entire `console` module when only the `log` function is
|
||||
// required.
|
||||
|
||||
// Allowed shell operators that we consider "safe" as they do not introduce
|
||||
// side‑effects on their own (unlike redirections). Parentheses and braces for
|
||||
// grouping are excluded for simplicity.
|
||||
const SAFE_SHELL_OPERATORS: ReadonlySet<string> = new Set([
|
||||
"&&",
|
||||
"||",
|
||||
"|",
|
||||
";",
|
||||
]);
|
||||
|
||||
// Lazily resolve heavy dependencies at runtime to avoid test environments
|
||||
// (which might not have the @lib alias configured) from failing at import
|
||||
// time. If the modules cannot be loaded we fall back to permissive stub
|
||||
// implementations so that basic functionality – like unit‑testing small UI
|
||||
// helpers – continues to work without the full codex‑lib dependency tree.
|
||||
|
||||
let isSafeCommand: (cmd: Array<string>) => SafeCommandReason | null = () =>
|
||||
null;
|
||||
let shellQuoteParse:
|
||||
| ((cmd: string, env?: Record<string, string | undefined>) => Array<unknown>)
|
||||
| undefined;
|
||||
let formatCommandForDisplay: (cmd: Array<string>) => string = (cmd) =>
|
||||
cmd.join(" ");
|
||||
|
||||
async function loadLibs(): Promise<void> {
|
||||
try {
|
||||
const approvals = await import("@lib/approvals.js");
|
||||
if (typeof approvals.isSafeCommand === "function") {
|
||||
isSafeCommand = approvals.isSafeCommand;
|
||||
}
|
||||
} catch {
|
||||
// ignore – keep stub
|
||||
}
|
||||
try {
|
||||
const fmt = await import("@lib/format-command.js");
|
||||
if (typeof fmt.formatCommandForDisplay === "function") {
|
||||
formatCommandForDisplay = fmt.formatCommandForDisplay;
|
||||
}
|
||||
} catch {
|
||||
// ignore – keep stub
|
||||
}
|
||||
try {
|
||||
const sq = await import("shell-quote");
|
||||
if (typeof sq.parse === "function") {
|
||||
shellQuoteParse = sq.parse as typeof shellQuoteParse;
|
||||
}
|
||||
} catch {
|
||||
// ignore – keep stub
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the dynamic import in the background; callers that need the real
|
||||
// implementation should await the returned promise (parsers currently does not
|
||||
// require this for correctness during tests).
|
||||
void loadLibs();
|
||||
|
||||
export function parseToolCallOutput(toolCallOutput: string): {
|
||||
output: string;
|
||||
metadata: ExecOutputMetadata;
|
||||
} {
|
||||
try {
|
||||
const { output, metadata } = JSON.parse(toolCallOutput);
|
||||
return {
|
||||
output,
|
||||
metadata,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
output: `Failed to parse JSON result`,
|
||||
metadata: {
|
||||
exit_code: 1,
|
||||
duration_seconds: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseToolCall(
|
||||
toolCall: ResponseFunctionToolCall,
|
||||
): CommandReviewDetails | undefined {
|
||||
const toolCallArgs = parseToolCallArguments(toolCall.arguments);
|
||||
if (toolCallArgs == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { cmd } = toolCallArgs;
|
||||
const cmdReadableText = formatCommandForDisplay(cmd);
|
||||
|
||||
const autoApproval = computeAutoApproval(cmd);
|
||||
|
||||
return {
|
||||
cmd,
|
||||
cmdReadableText,
|
||||
autoApproval,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If toolCallArguments is a string of JSON that can be parsed into an object
|
||||
* with a "cmd" or "command" property that is an `Array<string>`, then returns
|
||||
* that array. Otherwise, returns undefined.
|
||||
*/
|
||||
export function parseToolCallArguments(
|
||||
toolCallArguments: string,
|
||||
): ExecInput | undefined {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(toolCallArguments);
|
||||
} catch (err) {
|
||||
log(`Failed to parse toolCall.arguments: ${toolCallArguments}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof json !== "object" || json == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { cmd, command } = json as Record<string, unknown>;
|
||||
const commandArray = toStringArray(cmd) ?? toStringArray(command);
|
||||
if (commandArray == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// @ts-expect-error timeout and workdir may not exist on json.
|
||||
const { timeout, workdir } = json;
|
||||
return {
|
||||
cmd: commandArray,
|
||||
workdir: typeof workdir === "string" ? workdir : undefined,
|
||||
timeoutInMillis: typeof timeout === "number" ? timeout : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toStringArray(obj: unknown): Array<string> | undefined {
|
||||
if (Array.isArray(obj) && obj.every((item) => typeof item === "string")) {
|
||||
const arrayOfStrings: Array<string> = obj;
|
||||
return arrayOfStrings;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- safe‑command helpers ----------------
|
||||
|
||||
/**
|
||||
* Attempts to determine whether `cmd` is composed exclusively of safe
|
||||
* sub‑commands combined using only operators from the SAFE_SHELL_OPERATORS
|
||||
* allow‑list. Returns the `SafeCommandReason` (taken from the first sub‑command)
|
||||
* if the whole expression is safe; otherwise returns `null`.
|
||||
*/
|
||||
function computeAutoApproval(cmd: Array<string>): SafeCommandReason | null {
|
||||
// Fast path: a simple command with no shell processing.
|
||||
const direct = isSafeCommand(cmd);
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
// For expressions like ["bash", "-lc", "ls && pwd"] break down the inner
|
||||
// string and verify each segment.
|
||||
if (
|
||||
cmd.length === 3 &&
|
||||
cmd[0] === "bash" &&
|
||||
cmd[1] === "-lc" &&
|
||||
typeof cmd[2] === "string" &&
|
||||
shellQuoteParse
|
||||
) {
|
||||
const parsed = shellQuoteParse(cmd[2], process.env ?? {});
|
||||
if (parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let current: Array<string> = [];
|
||||
let first: SafeCommandReason | null = null;
|
||||
|
||||
const flush = (): boolean => {
|
||||
if (current.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const safe = isSafeCommand(current);
|
||||
if (safe == null) {
|
||||
return false;
|
||||
}
|
||||
if (!first) {
|
||||
first = safe;
|
||||
}
|
||||
current = [];
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const part of parsed) {
|
||||
if (typeof part === "string") {
|
||||
// Simple word/argument token.
|
||||
if (part === "(" || part === ")" || part === "{" || part === "}") {
|
||||
// We treat explicit grouping tokens as unsafe because their
|
||||
// semantics depend on the shell evaluation environment.
|
||||
return null;
|
||||
}
|
||||
current.push(part);
|
||||
} else if (part && typeof part === "object") {
|
||||
const opToken = part as { op?: string };
|
||||
if (typeof opToken.op === "string") {
|
||||
if (!flush()) {
|
||||
return null;
|
||||
}
|
||||
if (!SAFE_SHELL_OPERATORS.has(opToken.op)) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Unknown object token kind (e.g. redirection) – treat as unsafe.
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Token types such as numbers / booleans are unexpected – treat as unsafe.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!flush()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
53
codex-cli/src/utils/session.ts
Normal file
53
codex-cli/src/utils/session.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const CLI_VERSION = "0.1.04152057"; // Must be in sync with package.json.
|
||||
export const ORIGIN = "codex_cli_ts";
|
||||
|
||||
export type TerminalChatSession = {
|
||||
/** Globally unique session identifier */
|
||||
id: string;
|
||||
/** The OpenAI username associated with this session */
|
||||
user: string;
|
||||
/** Version identifier of the Codex CLI that produced the session */
|
||||
version: string;
|
||||
/** The model used for the conversation */
|
||||
model: string;
|
||||
/** ISO timestamp noting when the session was persisted */
|
||||
timestamp: string;
|
||||
/** Optional custom instructions that were active for the run */
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
let sessionId = "";
|
||||
|
||||
/**
|
||||
* Update the globally tracked session identifier.
|
||||
* Passing an empty string clears the current session.
|
||||
*/
|
||||
export function setSessionId(id: string): void {
|
||||
sessionId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the currently active session identifier, or an empty string when
|
||||
* no session is active.
|
||||
*/
|
||||
export function getSessionId(): string {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
let currentModel = "";
|
||||
|
||||
/**
|
||||
* Record the model that is currently being used for the conversation.
|
||||
* Setting an empty string clears the record so the next agent run can update it.
|
||||
*/
|
||||
export function setCurrentModel(model: string): void {
|
||||
currentModel = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model that was last supplied to {@link setCurrentModel}.
|
||||
* If no model has been recorded yet, an empty string is returned.
|
||||
*/
|
||||
export function getCurrentModel(): string {
|
||||
return currentModel;
|
||||
}
|
||||
27
codex-cli/src/utils/short-path.ts
Normal file
27
codex-cli/src/utils/short-path.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import path from "path";
|
||||
|
||||
export function shortenPath(p: string, maxLength = 40): string {
|
||||
const home = process.env["HOME"];
|
||||
// Replace home directory with '~' if applicable.
|
||||
const displayPath =
|
||||
home !== undefined && p.startsWith(home) ? p.replace(home, "~") : p;
|
||||
if (displayPath.length <= maxLength) {
|
||||
return displayPath;
|
||||
}
|
||||
|
||||
const parts = displayPath.split(path.sep);
|
||||
let result = "";
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const candidate = path.join("~", "...", ...parts.slice(i));
|
||||
if (candidate.length <= maxLength) {
|
||||
result = candidate;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result || displayPath.slice(-maxLength);
|
||||
}
|
||||
|
||||
export function shortCwd(maxLength = 40): string {
|
||||
return shortenPath(process.cwd(), maxLength);
|
||||
}
|
||||
190
codex-cli/src/utils/singlepass/code_diff.ts
Normal file
190
codex-cli/src/utils/singlepass/code_diff.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { EditedFiles, FileOperation } from "./file_ops";
|
||||
|
||||
import { createTwoFilesPatch } from "diff";
|
||||
|
||||
/**************************************
|
||||
* ANSI color codes for output styling
|
||||
**************************************/
|
||||
const RED = "\u001b[31m";
|
||||
const GREEN = "\u001b[32m";
|
||||
const CYAN = "\u001b[36m";
|
||||
const YELLOW = "\u001b[33m";
|
||||
const RESET = "\u001b[0m";
|
||||
|
||||
/******************************************************
|
||||
* Generate a unified diff of two file contents
|
||||
* akin to generate_file_diff(original, updated)
|
||||
******************************************************/
|
||||
export function generateFileDiff(
|
||||
originalContent: string,
|
||||
updatedContent: string,
|
||||
filePath: string,
|
||||
): string {
|
||||
return createTwoFilesPatch(
|
||||
`${filePath} (original)`,
|
||||
`${filePath} (modified)`,
|
||||
originalContent,
|
||||
updatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
{ context: 5 },
|
||||
);
|
||||
}
|
||||
|
||||
/******************************************************
|
||||
* Apply colorization to a unified diff
|
||||
* akin to generate_colored_diff(diff_content)
|
||||
******************************************************/
|
||||
export function generateColoredDiff(diffContent: string): string {
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
const coloredLines: Array<string> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+++") || line.startsWith("---")) {
|
||||
// keep these lines uncolored, preserving the original style
|
||||
coloredLines.push(line);
|
||||
} else if (line.startsWith("+")) {
|
||||
// color lines that begin with + but not +++
|
||||
coloredLines.push(`${GREEN}${line}${RESET}`);
|
||||
} else if (line.startsWith("-")) {
|
||||
// color lines that begin with - but not ---
|
||||
coloredLines.push(`${RED}${line}${RESET}`);
|
||||
} else if (line.startsWith("@@")) {
|
||||
// hunk header
|
||||
coloredLines.push(`${CYAN}${line}${RESET}`);
|
||||
} else {
|
||||
coloredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return coloredLines.join("\n");
|
||||
}
|
||||
|
||||
/******************************************************
|
||||
* Count lines added and removed in a unified diff.
|
||||
* akin to generate_diff_stats(diff_content).
|
||||
******************************************************/
|
||||
export function generateDiffStats(diffContent: string): [number, number] {
|
||||
let linesAdded = 0;
|
||||
let linesRemoved = 0;
|
||||
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
linesAdded += 1;
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
linesRemoved += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [linesAdded, linesRemoved];
|
||||
}
|
||||
|
||||
/************************************************
|
||||
* Helper for generating a short header block
|
||||
************************************************/
|
||||
function generateDiffHeader(fileOp: FileOperation): string {
|
||||
const TTY_WIDTH = 80;
|
||||
const separatorLine = "=".repeat(TTY_WIDTH) + "\n";
|
||||
const subSeparatorLine = "-".repeat(TTY_WIDTH) + "\n";
|
||||
const headerLine = `Changes for: ${fileOp.path}`;
|
||||
return separatorLine + headerLine + "\n" + subSeparatorLine;
|
||||
}
|
||||
|
||||
/****************************************************************
|
||||
* Summarize diffs for each file operation that has differences.
|
||||
* akin to generate_diff_summary(edited_files, original_files)
|
||||
****************************************************************/
|
||||
export function generateDiffSummary(
|
||||
editedFiles: EditedFiles,
|
||||
originalFileContents: Record<string, string>,
|
||||
): [string, Array<FileOperation>] {
|
||||
let combinedDiffs = "";
|
||||
const opsToApply: Array<FileOperation> = [];
|
||||
|
||||
for (const fileOp of editedFiles.ops) {
|
||||
const diffHeader = generateDiffHeader(fileOp);
|
||||
|
||||
if (fileOp.delete) {
|
||||
// file will be deleted
|
||||
combinedDiffs += diffHeader + "File will be deleted.\n\n";
|
||||
opsToApply.push(fileOp);
|
||||
continue;
|
||||
} else if (fileOp.move_to) {
|
||||
combinedDiffs +=
|
||||
diffHeader + `File will be moved to: ${fileOp.move_to}\n\n`;
|
||||
opsToApply.push(fileOp);
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise it's an update
|
||||
const originalContent = originalFileContents[fileOp.path] ?? "";
|
||||
const updatedContent = fileOp.updated_full_content ?? "";
|
||||
|
||||
if (originalContent === updatedContent) {
|
||||
// no changes => skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffOutput = generateFileDiff(
|
||||
originalContent,
|
||||
updatedContent,
|
||||
fileOp.path,
|
||||
);
|
||||
if (diffOutput.trim()) {
|
||||
const coloredDiff = generateColoredDiff(diffOutput);
|
||||
combinedDiffs += diffHeader + coloredDiff + "\n";
|
||||
opsToApply.push(fileOp);
|
||||
}
|
||||
}
|
||||
|
||||
return [combinedDiffs, opsToApply];
|
||||
}
|
||||
|
||||
/****************************************************************
|
||||
* Generate a user-friendly summary of the pending file ops.
|
||||
* akin to generate_edit_summary(ops_to_apply, original_files)
|
||||
****************************************************************/
|
||||
export function generateEditSummary(
|
||||
opsToApply: Array<FileOperation>,
|
||||
originalFileContents: Record<string, string>,
|
||||
): string {
|
||||
if (!opsToApply || opsToApply.length === 0) {
|
||||
return "No changes detected.";
|
||||
}
|
||||
|
||||
const summaryLines: Array<string> = [];
|
||||
for (const fileOp of opsToApply) {
|
||||
if (fileOp.delete) {
|
||||
// red for deleted
|
||||
summaryLines.push(`${RED} Deleted: ${fileOp.path}${RESET}`);
|
||||
} else if (fileOp.move_to) {
|
||||
// yellow for moved
|
||||
summaryLines.push(
|
||||
`${YELLOW} Moved: ${fileOp.path} -> ${fileOp.move_to}${RESET}`,
|
||||
);
|
||||
} else {
|
||||
const originalContent = originalFileContents[fileOp.path];
|
||||
const updatedContent = fileOp.updated_full_content ?? "";
|
||||
if (originalContent === undefined) {
|
||||
// newly created file
|
||||
const linesAdded = updatedContent.split(/\r?\n/).length;
|
||||
summaryLines.push(
|
||||
`${GREEN} Created: ${fileOp.path} (+${linesAdded} lines)${RESET}`,
|
||||
);
|
||||
} else {
|
||||
const diffOutput = generateFileDiff(
|
||||
originalContent,
|
||||
updatedContent,
|
||||
fileOp.path,
|
||||
);
|
||||
const [added, removed] = generateDiffStats(diffOutput);
|
||||
summaryLines.push(
|
||||
` Modified: ${fileOp.path} (${GREEN}+${added}${RESET}/${RED}-${removed}${RESET})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summaryLines.join("\n");
|
||||
}
|
||||
64
codex-cli/src/utils/singlepass/context.ts
Normal file
64
codex-cli/src/utils/singlepass/context.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** Represents file contents with a path and its full text. */
|
||||
export interface FileContent {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the context for a task, including:
|
||||
* - A prompt (the user's request)
|
||||
* - A list of input paths being considered editable
|
||||
* - A directory structure overview
|
||||
* - A collection of file contents
|
||||
*/
|
||||
export interface TaskContext {
|
||||
prompt: string;
|
||||
input_paths: Array<string>;
|
||||
input_paths_structure: string;
|
||||
files: Array<FileContent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a string version of the TaskContext, including a note about important output requirements,
|
||||
* summary of the directory structure (unless omitted), and an XML-like listing of the file contents.
|
||||
*
|
||||
* The user is instructed to produce only changes for files strictly under the specified paths
|
||||
* and provide full file contents in any modifications.
|
||||
*/
|
||||
export function renderTaskContext(taskContext: TaskContext): string {
|
||||
const inputPathsJoined = taskContext.input_paths.join(", ");
|
||||
return `
|
||||
Complete the following task: ${taskContext.prompt}
|
||||
|
||||
# IMPORTANT OUTPUT REQUIREMENTS
|
||||
- UNDER NO CIRCUMSTANCES PRODUCE PARTIAL OR TRUNCATED FILE CONTENT. You MUST provide the FULL AND FINAL content for every file modified.
|
||||
- ALWAYS INCLUDE THE COMPLETE UPDATED VERSION OF THE FILE, do not omit or only partially include lines.
|
||||
- ONLY produce changes for files located strictly under ${inputPathsJoined}.
|
||||
- ALWAYS produce absolute paths in the output.
|
||||
- Do not delete or change code UNRELATED to the task.
|
||||
|
||||
# **Directory structure**
|
||||
${taskContext.input_paths_structure}
|
||||
|
||||
# Files
|
||||
${renderFilesToXml(taskContext.files)}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the provided list of FileContent objects into a custom XML-like format.
|
||||
*
|
||||
* For each file, we embed the content in a CDATA section.
|
||||
*/
|
||||
function renderFilesToXml(files: Array<FileContent>): string {
|
||||
let xmlContent = "<files>";
|
||||
for (const fc of files) {
|
||||
xmlContent += `
|
||||
<file>
|
||||
<path>${fc.path}</path>
|
||||
<content><![CDATA[${fc.content}]]></content>
|
||||
</file>`;
|
||||
}
|
||||
xmlContent += "\n</files>";
|
||||
return xmlContent;
|
||||
}
|
||||
409
codex-cli/src/utils/singlepass/context_files.ts
Normal file
409
codex-cli/src/utils/singlepass/context_files.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import * as fsSync from "fs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/** Represents file contents with absolute path. */
|
||||
export interface FileContent {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A simple LRU cache entry structure. */
|
||||
interface CacheEntry {
|
||||
/** Last modification time of the file (epoch ms). */
|
||||
mtime: number;
|
||||
/** Size of the file in bytes. */
|
||||
size: number;
|
||||
/** Entire file content. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal LRU-based file cache to store file contents keyed by absolute path.
|
||||
* We store (mtime, size, content). If a file's mtime or size changes, we consider
|
||||
* the cache invalid and re-read.
|
||||
*/
|
||||
class LRUFileCache {
|
||||
private maxSize: number;
|
||||
private cache: Map<string, CacheEntry>;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cached entry for the given path, if it exists.
|
||||
* If found, we re-insert it in the map to mark it as recently used.
|
||||
*/
|
||||
get(key: string): CacheEntry | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
// Re-insert to maintain recency
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update an entry in the cache.
|
||||
*/
|
||||
set(key: string, entry: CacheEntry): void {
|
||||
// if key already in map, delete it so that insertion below sets recency.
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
this.cache.set(key, entry);
|
||||
|
||||
// If over capacity, evict the least recently used entry.
|
||||
if (this.cache.size > this.maxSize) {
|
||||
const firstKey = this.cache.keys().next();
|
||||
if (!firstKey.done) {
|
||||
this.cache.delete(firstKey.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry from the cache.
|
||||
*/
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all keys in the cache (for pruning old files, etc.).
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
return this.cache.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// Environment-based defaults
|
||||
const MAX_CACHE_ENTRIES = parseInt(
|
||||
process.env["TENX_FILE_CACHE_MAX_ENTRIES"] || "1000",
|
||||
10,
|
||||
);
|
||||
|
||||
// Global LRU file cache instance.
|
||||
const FILE_CONTENTS_CACHE = new LRUFileCache(MAX_CACHE_ENTRIES);
|
||||
|
||||
// Default list of glob patterns to ignore if the user doesn't provide a custom ignore file.
|
||||
const DEFAULT_IGNORE_PATTERNS = `
|
||||
# Binaries and large media
|
||||
*.woff
|
||||
*.exe
|
||||
*.dll
|
||||
*.bin
|
||||
*.dat
|
||||
*.pdf
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.bmp
|
||||
*.tiff
|
||||
*.ico
|
||||
*.zip
|
||||
*.tar
|
||||
*.gz
|
||||
*.rar
|
||||
*.7z
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.wmv
|
||||
|
||||
# Build and distribution
|
||||
build/*
|
||||
dist/*
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# Python artifacts
|
||||
*.egg-info/*
|
||||
__pycache__/*
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/*
|
||||
.ruff_cache/*
|
||||
venv/*
|
||||
.venv/*
|
||||
env/*
|
||||
|
||||
# Rust artifacts
|
||||
target/*
|
||||
Cargo.lock
|
||||
|
||||
# Node.js artifacts
|
||||
*.tsbuildinfo
|
||||
node_modules/*
|
||||
package-lock.json
|
||||
|
||||
# Environment files
|
||||
.env/*
|
||||
|
||||
# Git
|
||||
.git/*
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Hidden files
|
||||
.*/*
|
||||
.*
|
||||
`;
|
||||
|
||||
function _read_default_patterns_file(filePath?: string): string {
|
||||
if (!filePath) {
|
||||
return DEFAULT_IGNORE_PATTERNS;
|
||||
}
|
||||
|
||||
return fsSync.readFileSync(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/** Loads ignore patterns from a file (or a default list) and returns a list of RegExp patterns. */
|
||||
export function loadIgnorePatterns(filePath?: string): Array<RegExp> {
|
||||
try {
|
||||
const raw = _read_default_patterns_file(filePath);
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const cleaned = lines
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l && !l.startsWith("#"));
|
||||
|
||||
// Convert each pattern to a RegExp with a leading '*/'.
|
||||
const regs = cleaned.map((pattern: string) => {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
const finalRe = `^(?:(?:(?:.*/)?)(?:${escaped}))$`;
|
||||
return new RegExp(finalRe, "i");
|
||||
});
|
||||
return regs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a given path is ignored by any of the compiled patterns. */
|
||||
export function shouldIgnorePath(
|
||||
p: string,
|
||||
compiledPatterns: Array<RegExp>,
|
||||
): boolean {
|
||||
const normalized = path.resolve(p);
|
||||
for (const regex of compiledPatterns) {
|
||||
if (regex.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds an ASCII representation of a directory structure, given a list
|
||||
* of file paths.
|
||||
*/
|
||||
export function makeAsciiDirectoryStructure(
|
||||
rootPath: string,
|
||||
filePaths: Array<string>,
|
||||
): string {
|
||||
const root = path.resolve(rootPath);
|
||||
|
||||
// We'll store a nested object. Directories => sub-tree or null if it's a file.
|
||||
interface DirTree {
|
||||
[key: string]: DirTree | null;
|
||||
}
|
||||
|
||||
const tree: DirTree = {};
|
||||
|
||||
for (const file of filePaths) {
|
||||
const resolved = path.resolve(file);
|
||||
let relPath: string;
|
||||
try {
|
||||
const rp = path.relative(root, resolved);
|
||||
// If it's outside of root, skip.
|
||||
if (rp.startsWith("..")) {
|
||||
continue;
|
||||
}
|
||||
relPath = rp;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parts = relPath.split(path.sep);
|
||||
let current: DirTree = tree;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
if (i === parts.length - 1) {
|
||||
// file
|
||||
current[part] = null;
|
||||
} else {
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as DirTree;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines: Array<string> = [root];
|
||||
|
||||
function recurse(node: DirTree, prefix: string): void {
|
||||
const entries = Object.keys(node).sort((a, b) => {
|
||||
// Directories first, then files
|
||||
const aIsDir = node[a] != null;
|
||||
const bIsDir = node[b] != null;
|
||||
if (aIsDir && !bIsDir) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsDir && bIsDir) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isLast = i === entries.length - 1;
|
||||
const connector = isLast ? "└──" : "├──";
|
||||
const isDir = node[entry] != null;
|
||||
lines.push(`${prefix}${connector} ${entry}`);
|
||||
if (isDir) {
|
||||
const newPrefix = prefix + (isLast ? " " : "│ ");
|
||||
recurse(node[entry] as DirTree, newPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurse(tree, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all files under rootPath that are not ignored, skipping symlinks.
|
||||
* Then for each file, we check if it's in the LRU cache. If not or changed, we read it.
|
||||
* Returns an array of FileContent.
|
||||
*
|
||||
* After collecting, we remove from the cache any file that no longer exists in the BFS.
|
||||
*/
|
||||
export async function getFileContents(
|
||||
rootPath: string,
|
||||
compiledPatterns: Array<RegExp>,
|
||||
): Promise<Array<FileContent>> {
|
||||
const root = path.resolve(rootPath);
|
||||
const candidateFiles: Array<string> = [];
|
||||
|
||||
// BFS queue of directories
|
||||
const queue: Array<string> = [root];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentDir = queue.pop()!;
|
||||
let dirents: Array<fsSync.Dirent> = [];
|
||||
try {
|
||||
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
try {
|
||||
const resolved = path.resolve(currentDir, dirent.name);
|
||||
// skip symlinks
|
||||
const lstat = await fs.lstat(resolved);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (dirent.isDirectory()) {
|
||||
// check if ignored
|
||||
if (!shouldIgnorePath(resolved, compiledPatterns)) {
|
||||
queue.push(resolved);
|
||||
}
|
||||
} else if (dirent.isFile()) {
|
||||
// check if ignored
|
||||
if (!shouldIgnorePath(resolved, compiledPatterns)) {
|
||||
candidateFiles.push(resolved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We'll read the stat for each candidate file, see if we can skip reading from cache.
|
||||
const results: Array<FileContent> = [];
|
||||
|
||||
// We'll keep track of which files we actually see.
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
candidateFiles.map(async (filePath) => {
|
||||
seenPaths.add(filePath);
|
||||
let st: fsSync.Stats | null = null;
|
||||
try {
|
||||
st = await fs.stat(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!st) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cEntry = FILE_CONTENTS_CACHE.get(filePath);
|
||||
if (
|
||||
cEntry &&
|
||||
Math.abs(cEntry.mtime - st.mtime.getTime()) < 1 &&
|
||||
cEntry.size === st.size
|
||||
) {
|
||||
// same mtime, same size => use cache
|
||||
results.push({ path: filePath, content: cEntry.content });
|
||||
} else {
|
||||
// read file
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const content = buf.toString("utf-8");
|
||||
// store in cache
|
||||
FILE_CONTENTS_CACHE.set(filePath, {
|
||||
mtime: st.mtime.getTime(),
|
||||
size: st.size,
|
||||
content,
|
||||
});
|
||||
results.push({ path: filePath, content });
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Now remove from cache any file that wasn't encountered.
|
||||
const currentKeys = [...FILE_CONTENTS_CACHE.keys()];
|
||||
for (const key of currentKeys) {
|
||||
if (!seenPaths.has(key)) {
|
||||
FILE_CONTENTS_CACHE.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// sort results by path
|
||||
results.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return results;
|
||||
}
|
||||
208
codex-cli/src/utils/singlepass/context_limit.ts
Normal file
208
codex-cli/src/utils/singlepass/context_limit.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import type { FileContent } from "./context_files.js";
|
||||
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Builds file-size and total-size maps for the provided files, keyed by absolute path.
|
||||
*
|
||||
* @param root - The root directory (absolute path) to treat as the top-level. Ascension stops here.
|
||||
* @param files - An array of FileContent objects, each with a path and content.
|
||||
* @returns A tuple [fileSizeMap, totalSizeMap] where:
|
||||
* - fileSizeMap[path] = size (in characters) of the file
|
||||
* - totalSizeMap[path] = cumulative size (in characters) for path (file or directory)
|
||||
*/
|
||||
export function computeSizeMap(
|
||||
root: string,
|
||||
files: Array<FileContent>,
|
||||
): [Record<string, number>, Record<string, number>] {
|
||||
const rootAbs = path.resolve(root);
|
||||
const fileSizeMap: Record<string, number> = {};
|
||||
const totalSizeMap: Record<string, number> = {};
|
||||
|
||||
for (const fc of files) {
|
||||
const pAbs = path.resolve(fc.path);
|
||||
const length = fc.content.length;
|
||||
|
||||
// Record size in fileSizeMap
|
||||
fileSizeMap[pAbs] = length;
|
||||
|
||||
// Ascend from pAbs up to root, adding size along the way.
|
||||
let current = pAbs;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
totalSizeMap[current] = (totalSizeMap[current] ?? 0) + length;
|
||||
if (current === rootAbs) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
// If we've reached the top or gone outside root, break.
|
||||
if (parent === current) {
|
||||
// e.g. we're at "/" in a *nix system or some root in Windows.
|
||||
break;
|
||||
}
|
||||
// If we have gone above the root (meaning the parent no longer starts with rootAbs), break.
|
||||
if (!parent.startsWith(rootAbs) && parent !== rootAbs) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
return [fileSizeMap, totalSizeMap];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a mapping of directories to their immediate children. The keys and values
|
||||
* are absolute paths. For each path in totalSizeMap (except the root itself), we find
|
||||
* its parent (if also in totalSizeMap) and add the path to the children of that parent.
|
||||
*
|
||||
* @param root - The root directory (absolute path).
|
||||
* @param totalSizeMap - A map from path -> cumulative size.
|
||||
* @returns A record that maps directory paths to arrays of child paths.
|
||||
*/
|
||||
export function buildChildrenMap(
|
||||
root: string,
|
||||
totalSizeMap: Record<string, number>,
|
||||
): Record<string, Array<string>> {
|
||||
const rootAbs = path.resolve(root);
|
||||
const childrenMap: Record<string, Array<string>> = {};
|
||||
|
||||
// Initialize all potential keys so that each path has an entry.
|
||||
for (const p of Object.keys(totalSizeMap)) {
|
||||
if (!childrenMap[p]) {
|
||||
childrenMap[p] = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of Object.keys(totalSizeMap)) {
|
||||
if (p === rootAbs) {
|
||||
continue;
|
||||
}
|
||||
const parent = path.dirname(p);
|
||||
|
||||
// If the parent is also tracked in totalSizeMap, we record p as a child.
|
||||
if (totalSizeMap[parent] !== undefined && parent !== p) {
|
||||
if (!childrenMap[parent]) {
|
||||
childrenMap[parent] = [];
|
||||
}
|
||||
|
||||
childrenMap[parent].push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the children.
|
||||
for (const val of Object.values(childrenMap)) {
|
||||
val.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively prints a directory/file tree, showing size usage.
|
||||
*
|
||||
* @param current - The current absolute path (directory or file) to print.
|
||||
* @param childrenMap - A mapping from directory paths to an array of their child paths.
|
||||
* @param fileSizeMap - A map from file path to file size (characters).
|
||||
* @param totalSizeMap - A map from path to total cumulative size.
|
||||
* @param prefix - The current prefix used for ASCII indentation.
|
||||
* @param isLast - Whether the current path is the last child in its parent.
|
||||
* @param contextLimit - The context limit for reference.
|
||||
*/
|
||||
export function printSizeTree(
|
||||
current: string,
|
||||
childrenMap: Record<string, Array<string>>,
|
||||
fileSizeMap: Record<string, number>,
|
||||
totalSizeMap: Record<string, number>,
|
||||
prefix: string,
|
||||
isLast: boolean,
|
||||
contextLimit: number,
|
||||
): void {
|
||||
const connector = isLast ? "└──" : "├──";
|
||||
const label = path.basename(current) || current;
|
||||
const totalSz = totalSizeMap[current] ?? 0;
|
||||
const percentageOfLimit =
|
||||
contextLimit > 0 ? (totalSz / contextLimit) * 100 : 0;
|
||||
|
||||
if (fileSizeMap[current] !== undefined) {
|
||||
// It's a file
|
||||
const fileSz = fileSizeMap[current];
|
||||
console.log(
|
||||
`${prefix}${connector} ${label} [file: ${fileSz} bytes, cumulative: ${totalSz} bytes, ${percentageOfLimit.toFixed(
|
||||
2,
|
||||
)}% of limit]`,
|
||||
);
|
||||
} else {
|
||||
// It's a directory
|
||||
console.log(
|
||||
`${prefix}${connector} ${label} [dir: ${totalSz} bytes, ${percentageOfLimit.toFixed(
|
||||
2,
|
||||
)}% of limit]`,
|
||||
);
|
||||
}
|
||||
|
||||
const newPrefix = prefix + (isLast ? " " : "│ ");
|
||||
const children = childrenMap[current] || [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
const childIsLast = i === children.length - 1;
|
||||
printSizeTree(
|
||||
child!,
|
||||
childrenMap,
|
||||
fileSizeMap,
|
||||
totalSizeMap,
|
||||
newPrefix,
|
||||
childIsLast,
|
||||
contextLimit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a size breakdown for the entire directory (and subpaths), listing cumulative percentages.
|
||||
*
|
||||
* @param directory - The directory path (absolute or relative) for which to print the breakdown.
|
||||
* @param files - The array of FileContent representing the files under that directory.
|
||||
* @param contextLimit - The maximum context character limit.
|
||||
*/
|
||||
export function printDirectorySizeBreakdown(
|
||||
directory: string,
|
||||
files: Array<FileContent>,
|
||||
contextLimit = 300_000,
|
||||
): void {
|
||||
const rootAbs = path.resolve(directory);
|
||||
const [fileSizeMap, totalSizeMap] = computeSizeMap(rootAbs, files);
|
||||
const childrenMap = buildChildrenMap(rootAbs, totalSizeMap);
|
||||
|
||||
console.log("\nContext size breakdown by directory and file:");
|
||||
|
||||
const rootTotal = totalSizeMap[rootAbs] ?? 0;
|
||||
const rootPct =
|
||||
contextLimit > 0 ? ((rootTotal / contextLimit) * 100).toFixed(2) : "0";
|
||||
|
||||
const rootLabel = path.basename(rootAbs) || rootAbs;
|
||||
console.log(`${rootLabel} [dir: ${rootTotal} bytes, ${rootPct}% of limit]`);
|
||||
|
||||
const rootChildren = childrenMap[rootAbs] || [];
|
||||
rootChildren.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
for (let i = 0; i < rootChildren.length; i++) {
|
||||
const child = rootChildren[i];
|
||||
const childIsLast = i === rootChildren.length - 1;
|
||||
printSizeTree(
|
||||
child!,
|
||||
childrenMap,
|
||||
fileSizeMap,
|
||||
totalSizeMap,
|
||||
"",
|
||||
childIsLast,
|
||||
contextLimit,
|
||||
);
|
||||
}
|
||||
}
|
||||
47
codex-cli/src/utils/singlepass/file_ops.ts
Normal file
47
codex-cli/src/utils/singlepass/file_ops.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Represents a file operation, including modifications, deletes, and moves.
|
||||
*/
|
||||
export const FileOperationSchema = z.object({
|
||||
/**
|
||||
* Absolute path to the file.
|
||||
*/
|
||||
path: z.string(),
|
||||
|
||||
/**
|
||||
* FULL CONTENT of the file after modification. Provides the FULL AND FINAL content of
|
||||
* the file after modification WITHOUT OMITTING OR TRUNCATING ANY PART OF THE FILE.
|
||||
*
|
||||
* Mutually exclusive with 'delete' and 'move_to'.
|
||||
*/
|
||||
updated_full_content: z.string().nullable().optional(),
|
||||
|
||||
/**
|
||||
* Set to true if the file is to be deleted.
|
||||
*
|
||||
* Mutually exclusive with 'updated_full_content' and 'move_to'.
|
||||
*/
|
||||
delete: z.boolean().nullable().optional(),
|
||||
|
||||
/**
|
||||
* New path of the file if it is to be moved.
|
||||
*
|
||||
* Mutually exclusive with 'updated_full_content' and 'delete'.
|
||||
*/
|
||||
move_to: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type FileOperation = z.infer<typeof FileOperationSchema>;
|
||||
|
||||
/**
|
||||
* Container for one or more FileOperation objects.
|
||||
*/
|
||||
export const EditedFilesSchema = z.object({
|
||||
/**
|
||||
* A list of file operations that are applied in order.
|
||||
*/
|
||||
ops: z.array(FileOperationSchema),
|
||||
});
|
||||
|
||||
export type EditedFiles = z.infer<typeof EditedFilesSchema>;
|
||||
61
codex-cli/src/utils/storage/save-rollout.ts
Normal file
61
codex-cli/src/utils/storage/save-rollout.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import { loadConfig } from "../config";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const SESSIONS_ROOT = path.join(os.homedir(), ".codex", "sessions");
|
||||
|
||||
async function saveRolloutToHomeSessions(
|
||||
items: Array<ResponseItem>,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(SESSIONS_ROOT, { recursive: true });
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const ts = timestamp.replace(/[:.]/g, "-").slice(0, 10);
|
||||
const filename = `rollout-${ts}-${sessionId}.json`;
|
||||
const filePath = path.join(SESSIONS_ROOT, filename);
|
||||
const config = loadConfig();
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
session: {
|
||||
timestamp,
|
||||
id: sessionId,
|
||||
instructions: config.instructions,
|
||||
},
|
||||
items,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save rollout to ${filePath}: `, error);
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let pendingItems: Array<ResponseItem> | null = null;
|
||||
|
||||
export function saveRollout(items: Array<ResponseItem>): void {
|
||||
pendingItems = items;
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (pendingItems) {
|
||||
saveRolloutToHomeSessions(pendingItems).catch(() => {});
|
||||
pendingItems = null;
|
||||
}
|
||||
debounceTimer = null;
|
||||
}, 2000);
|
||||
}
|
||||
82
codex-cli/src/utils/terminal.ts
Normal file
82
codex-cli/src/utils/terminal.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Instance } from "ink";
|
||||
import type React from "react";
|
||||
|
||||
let inkRenderer: Instance | null = null;
|
||||
|
||||
// Track whether the clean‑up routine has already executed so repeat calls are
|
||||
// silently ignored. This can happen when different exit paths (e.g. the raw
|
||||
// Ctrl‑C handler and the process "exit" event) both attempt to tidy up.
|
||||
let didRunOnExit = false;
|
||||
|
||||
export function setInkRenderer(renderer: Instance): void {
|
||||
inkRenderer = renderer;
|
||||
|
||||
if (process.env["CODEX_FPS_DEBUG"]) {
|
||||
let last = Date.now();
|
||||
const logFrame = () => {
|
||||
const now = Date.now();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[fps] frame in ${now - last}ms`);
|
||||
last = now;
|
||||
};
|
||||
|
||||
// Monkey‑patch the public rerender/unmount methods so we know when Ink
|
||||
// flushes a new frame. React’s internal renders eventually call
|
||||
// `rerender()` so this gives us a good approximation without poking into
|
||||
// private APIs.
|
||||
const origRerender = renderer.rerender.bind(renderer);
|
||||
renderer.rerender = (node: React.ReactNode) => {
|
||||
logFrame();
|
||||
return origRerender(node);
|
||||
};
|
||||
|
||||
const origClear = renderer.clear.bind(renderer);
|
||||
renderer.clear = () => {
|
||||
logFrame();
|
||||
return origClear();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTerminal(): void {
|
||||
if (process.env["CODEX_QUIET_MODE"] === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
// When using the alternate screen the content never scrolls, so we rarely
|
||||
// need a full clear. Still expose the behaviour when explicitly requested
|
||||
// (e.g. via Ctrl‑L) but avoid unnecessary clears on every render to minimise
|
||||
// flicker.
|
||||
if (inkRenderer) {
|
||||
inkRenderer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function onExit(): void {
|
||||
// Ensure the clean‑up logic only runs once even if multiple exit signals
|
||||
// (e.g. Ctrl‑C data handler *and* the process "exit" event) invoke this
|
||||
// function. Re‑running the sequence is mostly harmless but can lead to
|
||||
// duplicate log messages and increases the risk of confusing side‑effects
|
||||
// should future clean‑up steps become non‑idempotent.
|
||||
if (didRunOnExit) {
|
||||
return;
|
||||
}
|
||||
|
||||
didRunOnExit = true;
|
||||
|
||||
// First make sure Ink is properly unmounted so it can restore any terminal
|
||||
// state it modified (e.g. raw‑mode on stdin). Failing to do so leaves the
|
||||
// terminal in raw‑mode after the Node process has exited which looks like
|
||||
// a “frozen” shell – no input is echoed and Ctrl‑C/Z no longer work. This
|
||||
// regression was introduced when we switched from `inkRenderer.unmount()`
|
||||
// to letting `process.exit` terminate the program a few commits ago. By
|
||||
// explicitly unmounting here we ensure Ink performs its clean‑up logic
|
||||
// *before* we restore the primary screen buffer.
|
||||
if (inkRenderer) {
|
||||
try {
|
||||
inkRenderer.unmount();
|
||||
} catch {
|
||||
/* best‑effort – continue even if Ink throws */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user