Initial commit

Signed-off-by: Ilan Bigio <ilan@openai.com>
This commit is contained in:
Ilan Bigio
2025-04-16 12:56:08 -04:00
commit 59a180ddec
163 changed files with 30587 additions and 0 deletions

103
codex-cli/src/app.tsx Normal file
View 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
View 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 projectlevel 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. Doublecheck 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 CtrlC when stdin is in rawmode
// ---------------------------------------------------------------------------
if (process.stdin.isTTY) {
// Ensure we do not leave the terminal in raw mode if the user presses
// CtrlC while some other component has focus and Ink is intercepting
// input. Node does *not* emit a SIGINT in rawmode, 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 cleanup always runs, even when other code calls
// `process.exit()` directly.
process.once("exit", onExit);

View 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 longrunning requests without forcequitting the whole
// process. Ink exits automatically when it detects Ctrl+C unless
// `exitOnCtrlC` is disabled via the renderoptions, 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 {};

View 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 automaticapproval policies.
*
* The list of available modes is derived from the AutoApprovalMode enum so we
* stay in sync with the core agent behaviour. It reuses 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}
/>
);
}

View 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. autoapproved toolcall 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 propertyaccess errors.
*
* A short cast after the refinement keeps the implementation tidy while
* preserving typesafety.
*/}
<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);

View 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 noops 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
// monkeypatch internals of Node's `EventEmitter` solely for the benefit of
// Ink's stdin stub typesafety 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.
// eslintdisablenextline @typescript-eslint/nounsafeassignment
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 asis so that Ink can distinguish between plain
// <Enter> ("\r") and a bare linefeed ("\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 rawmode 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 inmemory 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 rawmode 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 rerender so the component reflects the mutated buffer.
setVersion((v) => v + 1);
}
}, [buffer, stdin, setRawMode]);
// ---------------------------------------------------------------------------
// Keyboard handling.
// ---------------------------------------------------------------------------
useInput(
(input, key) => {
if (!focus) {
return;
}
// Singlestep 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 (rawmode) and the
// inktestinglibrary stub which delivers only the raw byte (e.g. 0x05
// for CtrlE) 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) CSIu / 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: bit1 (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` bit2 (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) Singlebyte 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 readonly 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;

View 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 denyreason:
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:&nbsp;
<Text>{DEFAULT_DENY_MESSAGE}</Text>
</Text>
</Box>
)}
</>
)}
</Box>
</Box>
);
}

View 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>
);
}

View 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 twostep
// 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 singlebyte 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>
);
}

View 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 historynavigation logic when the
// DEBUG_TCI environment variable is truthy. The traces help while debugging
// unittest 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 forceremount 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 multiline 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 keypress. 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] ?? "");
// Remount 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
// multiline 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 twostep
// 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 singlebyte 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>
);
}

View 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>
);
}

View 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 difflike stdout easier to read.
// We exclude the typical diff file headers ('---', '+++') so they retain
// the default color. This is a besteffort heuristic and should be safe for
// nondiff 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>;
}

View 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);

View File

@@ -0,0 +1,113 @@
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
import { approximateTokensUsed } from "../../utils/approximate-tokens-used.js";
/**
* Typeguard that narrows a {@link ResponseItem} to one that represents a
* userauthored 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 deduplication 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 besteffort 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 longcontext 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 locallygenerated user messages dont 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 deduplicate 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 overkill 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;
}

View 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 rerender
// ────────────────────────────────────────────────────────────────
// 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(); // rerender 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
// rerender 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>
);
}

View 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;

View 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. autoapproved toolcall 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);

View 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]);
}

View File

@@ -0,0 +1,90 @@
import { Box, Text, useInput } from "ink";
import React from "react";
/**
* An overlay that lists the available slashcommands 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>
Slashcommands
</Text>
<Text>
<Text color="cyan">/help</Text> show this help overlay
</Text>
<Text>
<Text color="cyan">/model</Text> switch the LLM model insession
</Text>
<Text>
<Text color="cyan">/approval</Text> switch autoapproval mode
</Text>
<Text>
<Text color="cyan">/history</Text> show command &amp; file history
for this session
</Text>
<Text>
<Text color="cyan">/clear</Text> clear screen &amp; 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>
&nbsp; 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>
);
}

View 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;
}
// Besteffort 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 / execlike 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);
}
}
// Specialcase 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) Nonexec 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) };
}

View 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}
/>
);
}

View File

@@ -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>
);
}

View 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"; // Thirdparty / 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 inflight.
*/
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 {};

View 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 dependencyfree so it can be reused 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 currentlyselected 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 handpicked list of recommended / frequentlyused options
* at the top while still falling back to a deterministic alphabetical
* order for the rest of the list (they can simply presort 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
// prefetched 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 crystalclear */}
<Text dimColor>type to search · enter to confirm · esc to cancel</Text>
</Box>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * from "./select.js";

View 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;
}
}

View 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,
),
);
}

View 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,
);
}),
);
}

View 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;

View 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,
};
};

View 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 },
);
};

View 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>;
}

View 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} />;
}

View 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,
};
}

View 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;
}

View 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 autoapproved.
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: "Noop (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" });
});
});

View 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 allowlist. If so, the entire expression is
// considered autoapprovable.
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: "Noop (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 allowlist 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 `shellquote`, 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. `shellquote` 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 propertysafe even when
// `entry` is a `string`.
"op" in entry &&
typeof (entry as { op?: unknown }).op === "string"
);
}

View 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\\!\\""');
});
});

View 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
// boilerplate "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 `shellquote` 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(" ");
}
}

View File

@@ -0,0 +1,45 @@
import { parseApplyPatch } from "./parse-apply-patch";
import { expect, test, describe } from "vitest";
// Helper function to unwrap a nonnull 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();
});
});

View 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");
}

View File

@@ -0,0 +1,852 @@
/* eslintdisable no-bitwise */
export type Direction =
| "left"
| "right"
| "up"
| "down"
| "wordLeft"
| "wordRight"
| "home"
| "end";
// Simple helper for wordwise 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;
}
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
function toCodePoints(str: string): Array<string> {
// [...str] or Array.from both iterate by UTF32 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 codepoint indices and rejoin.
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 (gitstyle $EDITOR workflow)
* =================================================================== */
/**
* Opens the current buffer contents in the users 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 inmemory
* 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 Gits behaviour and simplifies downstream
* controlflow (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 readonly 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 reevaluate whether the current scroll offset still keeps the
// caret visible. Calling `ensureCursorVisible` here guarantees that mere
// rerenders 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
// Windowsstyle \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),
});
}
/* ------------------------------------------------------------------
* Wordwise deletion helpers exposed publicly so tests (and future
* keybindings) 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 column0 this becomes a noop. */
deleteWordLeft(): void {
dbg("deleteWordLeft", { beforeCursor: this.getCursor() });
if (this.cursorCol === 0 && this.cursorRow === 0) {
return;
} // Nothing to delete
// When at column0 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 endofline ➜ 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;
}
}
/* =====================================================================
* Higherlevel 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 singleline 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 midline 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 column0 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 {
// noop 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 (=> rerender)
* ===================================================================== */
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 keypress 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 unmodified "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
View File

@@ -0,0 +1,65 @@
// Projectlocal 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/shellquote`) are introduced later these stubs will
// be overridden automatically by the higherpriority package typings.
declare module "shell-quote" {
/**
* Very small subset of the return tokens produced by `shellquote` 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 CodexLib imports so that the
// TypeScript compiler can resolve them.
/*
* The real `shellquote` types define `ControlOperator` as the literal set
* of operator strings that can appear in the parsed output. Recreating the
* exhaustive union is unnecessary for our purposes modelling it as a
* plain string is sufficient for typechecking 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 `shellquote` 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
* POSIXcompatible 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 inmemory strings.
*/
export function createTwoFilesPatch(
oldFileName: string,
newFileName: string,
oldStr: string,
newStr: string,
oldHeader?: string,
newHeader?: string,
options?: { context?: number },
): string;
}

File diff suppressed because it is too large Load Diff

View 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;
}
// -----------------------------------------------------------------------------
// Patchrelated 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];
}
// -----------------------------------------------------------------------------
// Highlevel 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);
}
});
}

View 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>";
}

View 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";
// ---------------------------------------------------------------------------
// Sessionlevel 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 (noop) 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;
}
}

View 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();
}

View 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");
}

View 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",
}

View 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;
};

View 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();

View 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 callersupplied 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
// longrunning 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 processgroup 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,
});
});
});
}

View File

@@ -0,0 +1,51 @@
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
/**
* Roughly estimate the number of languagemodel 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 contextwindow 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);
}

View 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 codebase.
//
// 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";

View 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",
}

View 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 nonzero 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 Gitspecific features) and
* a synchronous check keeps such callsites simple. The command is extremely
* fast (~1ms) so blocking the eventloop 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;
}
}

View 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: "" };
// Prestringified JSON variant so we dont 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 toplevel 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,
};
// -----------------------------------------------------------------------
// Firstrun 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 readonly 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 backwardcompatibility 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");
};

View 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;
}

View 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) {
// Fireandforget 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 inprocess.
*/
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 startup.
return true;
}
}

View 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
// sideeffects 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 unittesting small UI
// helpers continues to work without the full codexlib 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;
}
}
// ---------------- safecommand helpers ----------------
/**
* Attempts to determine whether `cmd` is composed exclusively of safe
* subcommands combined using only operators from the SAFE_SHELL_OPERATORS
* allowlist. Returns the `SafeCommandReason` (taken from the first subcommand)
* 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;
}

View 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;
}

View 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);
}

View 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");
}

View 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;
}

View 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;
}

View 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,
);
}
}

View 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>;

View 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);
}

View File

@@ -0,0 +1,82 @@
import type { Instance } from "ink";
import type React from "react";
let inkRenderer: Instance | null = null;
// Track whether the cleanup routine has already executed so repeat calls are
// silently ignored. This can happen when different exit paths (e.g. the raw
// CtrlC 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;
};
// Monkeypatch the public rerender/unmount methods so we know when Ink
// flushes a new frame. Reacts 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 CtrlL) but avoid unnecessary clears on every render to minimise
// flicker.
if (inkRenderer) {
inkRenderer.clear();
}
}
export function onExit(): void {
// Ensure the cleanup logic only runs once even if multiple exit signals
// (e.g. CtrlC data handler *and* the process "exit" event) invoke this
// function. Rerunning the sequence is mostly harmless but can lead to
// duplicate log messages and increases the risk of confusing sideeffects
// should future cleanup steps become nonidempotent.
if (didRunOnExit) {
return;
}
didRunOnExit = true;
// First make sure Ink is properly unmounted so it can restore any terminal
// state it modified (e.g. rawmode on stdin). Failing to do so leaves the
// terminal in rawmode after the Node process has exited which looks like
// a “frozen” shell no input is echoed and CtrlC/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 cleanup logic
// *before* we restore the primary screen buffer.
if (inkRenderer) {
try {
inkRenderer.unmount();
} catch {
/* besteffort continue even if Ink throws */
}
}
}