Compare commits

...

10 Commits

Author SHA1 Message Date
Eason Goodale
5481bf0cb4 update 2025-04-30 11:45:29 -07:00
Eason Goodale
41ef530683 ctr-g to clear images 2025-04-26 23:16:47 -07:00
Eason Goodale
1a0f4a5e93 file drag and drop 2025-04-26 12:53:47 -07:00
Eason Goodale
28410d62af cleanup 2025-04-26 11:25:24 -07:00
Eason Goodale
379b023a7f merge 2025-04-20 03:47:48 -07:00
Eason Goodale
b1cef74d8c linted and working 2025-04-20 01:26:40 -07:00
Eason Goodale
1e0a7cc313 lint 2025-04-19 22:55:37 -07:00
Eason Goodale
2bcc15a839 one image attached 2025-04-19 19:00:50 -07:00
Eason Goodale
d7d2c3f1e7 build working 2025-04-19 18:54:57 -07:00
Eason Goodale
35148c2ba9 tests pass 2025-04-19 18:49:29 -07:00
79 changed files with 8177 additions and 8145 deletions

View File

@@ -26,6 +26,7 @@ body:
label: Which model were you using?
description: Like `gpt-4.1`, `o4-mini`, `o3`, etc.
- type: input
id: platform
attributes:
label: What platform is your computer?
description: |

View File

@@ -19,40 +19,51 @@ jobs:
with:
node-version: 22
# Run codex-cli/ tasks first because they are higher signal.
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
run_install: false
- name: Install dependencies (codex-cli)
working-directory: codex-cli
run: npm ci
- name: Check formatting (codex-cli)
working-directory: codex-cli
run: npm run format
- name: Run tests (codex-cli)
working-directory: codex-cli
run: npm run test
- name: Lint (codex-cli)
working-directory: codex-cli
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
npm run lint -- \
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.store_path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
# Run all tasks using workspace filters
- name: Check TypeScript code formatting
working-directory: codex-cli
run: pnpm run format
- name: Check Markdown and config file formatting
run: pnpm run format
- name: Run tests
run: pnpm run test
- name: Lint
run: |
pnpm --filter @openai/codex exec -- eslint src tests --ext ts --ext tsx \
--report-unused-disable-directives \
--rule "no-console:error" \
--rule "no-debugger:error" \
--max-warnings=-1
- name: Typecheck (codex-cli)
working-directory: codex-cli
run: npm run typecheck
- name: Type-check
run: pnpm run typecheck
- name: Build (codex-cli)
working-directory: codex-cli
run: npm run build
# Run formatting checks in the root directory last.
- name: Install dependencies (root)
run: npm ci
- name: Check formatting (root)
run: npm run format
- name: Build
run: pnpm run build

13
.gitignore vendored
View File

@@ -1,5 +1,11 @@
# deps
# Node.js dependencies
node_modules
.pnpm-store
.pnpm-debug.log
# Keep pnpm-lock.yaml
!pnpm-lock.yaml
# build
dist/
@@ -20,6 +26,10 @@ result
*.swp
*~
# cli tools
CLAUDE.md
.claude/
# caches
.cache/
.turbo/
@@ -61,9 +71,8 @@ Icon?
# Unwanted package managers
.yarn/
yarn.lock
pnpm-lock.yaml
# release
package.json-e
session.ts-e
CHANGELOG.ignore.md
CHANGELOG.ignore.md

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm lint-staged

4
.npmrc Normal file
View File

@@ -0,0 +1,4 @@
shamefully-hoist=true
strict-peer-dependencies=false
node-linker=hoisted
prefer-workspace-packages=true

View File

@@ -1,2 +1,3 @@
/codex-cli/dist
/codex-cli/node_modules
pnpm-lock.yaml

View File

@@ -2,7 +2,21 @@
You can install any of these versions: `npm install -g codex@version`
## 0.1.2504172351
## `0.1.2504181820`
### 🚀 Features
- Add `/bug` report command (#312)
- Notify when a newer version is available (#333)
### 🐛 Bug Fixes
- Update context left display logic in TerminalChatInput component (#307)
- Improper spawn of sh on Windows Powershell (#318)
- `/bug` report command, thinking indicator (#381)
- Include pnpm lock file (#377)
## `0.1.2504172351`
### 🚀 Features
@@ -14,7 +28,7 @@ You can install any of these versions: `npm install -g codex@version`
- Raw-exec-process-group.test improve reliability and error handling (#280)
- Canonicalize the writeable paths used in seatbelt policy (#275)
## 0.1.2504172304
## `0.1.2504172304`
### 🚀 Features

70
PNPM.md Normal file
View File

@@ -0,0 +1,70 @@
# Migration to pnpm
This project has been migrated from npm to pnpm to improve dependency management and developer experience.
## Why pnpm?
- **Faster installation**: pnpm is significantly faster than npm and yarn
- **Disk space savings**: pnpm uses a content-addressable store to avoid duplication
- **Phantom dependency prevention**: pnpm creates a strict node_modules structure
- **Native workspaces support**: simplified monorepo management
## How to use pnpm
### Installation
```bash
# Global installation of pnpm
npm install -g pnpm@10.8.1
# Or with corepack (available with Node.js 22+)
corepack enable
corepack prepare pnpm@10.8.1 --activate
```
### Common commands
| npm command | pnpm equivalent |
| --------------- | ---------------- |
| `npm install` | `pnpm install` |
| `npm run build` | `pnpm run build` |
| `npm test` | `pnpm test` |
| `npm run lint` | `pnpm run lint` |
### Workspace-specific commands
| Action | Command |
| ------------------------------------------ | ---------------------------------------- |
| Run a command in a specific package | `pnpm --filter @openai/codex run build` |
| Install a dependency in a specific package | `pnpm --filter @openai/codex add lodash` |
| Run a command in all packages | `pnpm -r run test` |
## Monorepo structure
```
codex/
├── pnpm-workspace.yaml # Workspace configuration
├── .npmrc # pnpm configuration
├── package.json # Root dependencies and scripts
├── codex-cli/ # Main package
│ └── package.json # codex-cli specific dependencies
└── docs/ # Documentation (future package)
```
## Configuration files
- **pnpm-workspace.yaml**: Defines the packages included in the monorepo
- **.npmrc**: Configures pnpm behavior
- **Root package.json**: Contains shared scripts and dependencies
## CI/CD
CI/CD workflows have been updated to use pnpm instead of npm. Make sure your CI environments use pnpm 10.8.1 or higher.
## Known issues
If you encounter issues with pnpm, try the following solutions:
1. Remove the `node_modules` folder and `pnpm-lock.yaml` file, then run `pnpm install`
2. Make sure you're using pnpm 10.8.1 or higher
3. Verify that Node.js 22 or higher is installed

View File

@@ -244,6 +244,8 @@ npm install -g @openai/codex
yarn global add @openai/codex
# or
bun install -g @openai/codex
# or
pnpm add -g @openai/codex
```
</details>
@@ -256,9 +258,12 @@ bun install -g @openai/codex
git clone https://github.com/openai/codex.git
cd codex/codex-cli
# Enable corepack
corepack enable
# Install dependencies and build
npm install
npm run build
pnpm install
pnpm build
# Get the usage and the options
node ./dist/cli.js --help
@@ -267,7 +272,7 @@ node ./dist/cli.js --help
node ./dist/cli.js
# Or link the command globally for convenience
npm link
pnpm link
```
</details>
@@ -276,13 +281,27 @@ npm link
## Configuration
Codex looks for config files in **`~/.codex/`**.
Codex looks for config files in **`~/.codex/`** (either YAML or JSON format).
```yaml
# ~/.codex/config.yaml
model: o4-mini # Default model
approvalMode: suggest # or auto-edit, full-auto
fullAutoErrorMode: ask-user # or ignore-and-continue
notify: true # Enable desktop notifications for responses
safeCommands:
- npm test # Automatically approve npm test
- yarn lint # Automatically approve yarn lint
```
```json
// ~/.codex/config.json
{
"model": "o4-mini",
"approvalMode": "suggest",
"fullAutoErrorMode": "ask-user",
"notify": true
}
```
You can also define custom instructions:
@@ -376,7 +395,7 @@ More broadly we welcome contributions whether you are opening your very firs
- Create a _topic branch_ from `main` e.g. `feat/interactive-prompt`.
- Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs.
- Use `npm run test:watch` during development for superfast feedback.
- Use `pnpm test:watch` during development for superfast feedback.
- We use **Vitest** for unit tests, **ESLint** + **Prettier** for style, and **TypeScript** for typechecking.
- Before pushing, run the full test/type/lint suite:
@@ -403,14 +422,14 @@ npm test && npm run lint && npm run typecheck
```bash
# Watch mode (tests rerun on change)
npm run test:watch
pnpm test:watch
# Typecheck without emitting files
npm run typecheck
pnpm typecheck
# Automatically fix lint + prettier issues
npm run lint:fix
npm run format:fix
pnpm lint:fix
pnpm format:fix
```
#### Nix Flake Development
@@ -500,13 +519,13 @@ To publish a new version of the CLI, run the release scripts defined in `codex-c
1. Open the `codex-cli` directory
2. Make sure you're on a branch like `git checkout -b bump-version`
3. Bump the version and `CLI_VERSION` to current datetime: `npm run release:version`
3. Bump the version and `CLI_VERSION` to current datetime: `pnpm release:version`
4. Commit the version bump (with DCO sign-off):
```bash
git add codex-cli/src/utils/session.ts codex-cli/package.json
git commit -s -m "chore(release): codex-cli v$(node -p \"require('./codex-cli/package.json').version\")"
```
5. Copy README, build, and publish to npm: `npm run release`
5. Copy README, build, and publish to npm: `pnpm release`
6. Push to branch: `git push origin HEAD`
---

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run lint-staged to check files that are about to be committed
npm run pre-commit

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run tests and type checking before pushing
npm test && npm run typecheck

View File

@@ -1,9 +0,0 @@
{
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
]
}

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env sh
# resolve script path in case of symlink
SOURCE="$0"
while [ -h "$SOURCE" ]; do
DIR=$(dirname "$SOURCE")
SOURCE=$(readlink "$SOURCE")
case "$SOURCE" in
/*) ;; # absolute path
*) SOURCE="$DIR/$SOURCE" ;; # relative path
esac
done
DIR=$(cd "$(dirname "$SOURCE")" && pwd)
if command -v node >/dev/null 2>&1; then
exec node "$DIR/../dist/cli.js" "$@"
elif command -v bun >/dev/null 2>&1; then
exec bun "$DIR/../dist/cli.js" "$@"
else
echo "Error: node or bun is required to run codex" >&2
exit 1
fi

27
codex-cli/bin/codex.js Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Unified entry point for Codex CLI on all platforms
// Dynamically loads the compiled ESM bundle in dist/cli.js
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
// Determine this script's directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve the path to the compiled CLI bundle
const cliPath = path.resolve(__dirname, '../dist/cli.js');
const cliUrl = pathToFileURL(cliPath).href;
// Load and execute the CLI
(async () => {
try {
await import(cliUrl);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-undef
process.exit(1);
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "@openai/codex",
"version": "0.1.2504172351",
"version": "0.1.2504181820",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex"
"codex": "bin/codex.js"
},
"type": "module",
"engines": {
@@ -22,10 +22,8 @@
"build:dev": "NODE_ENV=development node build.mjs --dev && NODE_OPTIONS=--enable-source-maps node dist/cli-dev.js",
"release:readme": "cp ../README.md ./README.md",
"release:version": "TS=$(date +%y%m%d%H%M) && sed -E -i'' -e \"s/\\\"0\\.1\\.[0-9]{10}\\\"/\\\"0.1.${TS}\\\"/g\" package.json src/utils/session.ts",
"release:build-and-publish": "npm run build && npm publish",
"release": "npm run release:readme && npm run release:version && npm install && npm run release:build-and-publish",
"prepare": "husky",
"pre-commit": "lint-staged"
"release:build-and-publish": "pnpm run build && npm publish",
"release": "pnpm run release:readme && pnpm run release:version && pnpm install && pnpm run release:build-and-publish"
},
"files": [
"README.md",
@@ -47,7 +45,7 @@
"marked-terminal": "^7.3.0",
"meow": "^13.2.0",
"open": "^10.1.0",
"openai": "^4.89.0",
"openai": "^4.95.1",
"react": "^18.2.0",
"shell-quote": "^1.8.2",
"strip-ansi": "^7.1.0",
@@ -62,8 +60,10 @@
"@types/marked-terminal": "^6.1.1",
"@types/react": "^18.0.32",
"@types/shell-quote": "^1.7.5",
"@types/which": "^3.0.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"boxen": "^8.0.1",
"esbuild": "^0.25.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.32.2",
@@ -71,21 +71,13 @@
"eslint-plugin-react-refresh": "^0.4.19",
"husky": "^9.1.7",
"ink-testing-library": "^3.0.0",
"lint-staged": "^15.5.1",
"prettier": "^2.8.7",
"punycode": "^2.3.1",
"ts-node": "^10.9.1",
"typescript": "^5.0.3",
"vitest": "^3.0.9",
"whatwg-url": "^14.2.0"
},
"resolutions": {
"braces": "^3.0.3",
"micromatch": "^4.0.8",
"semver": "^7.7.1"
},
"overrides": {
"punycode": "^2.3.1"
"whatwg-url": "^14.2.0",
"which": "^5.0.0"
},
"repository": {
"type": "git",

View File

@@ -23,6 +23,16 @@ fi
WORK_DIR=$(realpath "$WORK_DIR")
# Generate a unique container name based on the normalized work directory
CONTAINER_NAME="codex_$(echo "$WORK_DIR" | sed 's/\//_/g' | sed 's/[^a-zA-Z0-9_-]//g')"
# Define cleanup to remove the container on script exit, ensuring no leftover containers
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
# Trap EXIT to invoke cleanup regardless of how the script terminates
trap cleanup EXIT
# Ensure a command is provided.
if [ "$#" -eq 0 ]; then
echo "Usage: $0 [--work_dir directory] \"COMMAND\""
@@ -35,11 +45,11 @@ if [ -z "$WORK_DIR" ]; then
exit 1
fi
# Remove any existing container named 'codex'.
docker rm -f codex 2>/dev/null || true
# Kill any existing container for the working directory using cleanup(), centralizing removal logic.
cleanup
# Run the container with the specified directory mounted at the same path inside the container.
docker run --name codex -d \
docker run --name "$CONTAINER_NAME" -d \
-e OPENAI_API_KEY \
--cap-add=NET_ADMIN \
--cap-add=NET_RAW \
@@ -48,7 +58,7 @@ docker run --name codex -d \
sleep infinity
# Initialize the firewall inside the container.
docker exec codex bash -c "sudo /usr/local/bin/init_firewall.sh"
docker exec "$CONTAINER_NAME" bash -c "sudo /usr/local/bin/init_firewall.sh"
# Execute the provided command in the container, ensuring it runs in the work directory.
# We use a parameterized bash command to safely handle the command and directory.
@@ -57,4 +67,4 @@ quoted_args=""
for arg in "$@"; do
quoted_args+=" $(printf '%q' "$arg")"
done
docker exec -it codex bash -c "cd \"/app$WORK_DIR\" && codex --full-auto ${quoted_args}"
docker exec -it "$CONTAINER_NAME" bash -c "cd \"/app$WORK_DIR\" && codex --full-auto ${quoted_args}"

View File

@@ -4,6 +4,7 @@ import {
identify_files_added,
identify_files_needed,
} from "./utils/agent/apply-patch";
import { loadConfig } from "./utils/config";
import * as path from "path";
import { parse } from "shell-quote";
@@ -296,6 +297,24 @@ export function isSafeCommand(
): SafeCommandReason | null {
const [cmd0, cmd1, cmd2, cmd3] = command;
const config = loadConfig();
if (config.safeCommands && Array.isArray(config.safeCommands)) {
for (const safe of config.safeCommands) {
// safe: "npm test" → ["npm", "test"]
const safeArr = typeof safe === "string" ? safe.trim().split(/\s+/) : [];
if (
safeArr.length > 0 &&
safeArr.length <= command.length &&
safeArr.every((v, i) => v === command[i])
) {
return {
reason: "User-defined safe command",
group: "User config",
};
}
}
}
switch (cmd0) {
case "cd":
return {

View File

@@ -17,6 +17,7 @@ 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 { checkForUpdates } from "./utils/check-updates";
import {
loadConfig,
PRETTY_PRINT,
@@ -70,6 +71,9 @@ const cli = meow(
--full-stdout Do not truncate stdout/stderr from command outputs
--notify Enable desktop notifications for responses
--flex-mode Use "flex-mode" processing mode for the request (only supported
with models o3 and o4-mini)
Dangerous options
--dangerously-auto-approve-everything
Skip all confirmation prompts and execute commands without
@@ -139,6 +143,11 @@ const cli = meow(
type: "string",
description: "Path to a markdown file to include as project doc",
},
flexMode: {
type: "boolean",
description:
"Enable the flex-mode service tier (only supported by models o3 and o4-mini)",
},
fullStdout: {
type: "boolean",
description:
@@ -249,9 +258,29 @@ config = {
apiKey,
...config,
model: model ?? config.model,
flexMode: Boolean(cli.flags.flexMode),
notify: Boolean(cli.flags.notify),
};
// Check for updates after loading config
// This is important because we write state file in the config dir
await checkForUpdates().catch();
// ---------------------------------------------------------------------------
// --flex-mode validation (only allowed for o3 and o4-mini)
// ---------------------------------------------------------------------------
if (cli.flags.flexMode) {
const allowedFlexModels = new Set(["o3", "o4-mini"]);
if (!allowedFlexModels.has(config.model)) {
// eslint-disable-next-line no-console
console.error(
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
`Current model: '${config.model}'.`,
);
process.exit(1);
}
}
if (!(await isModelSupportedForResponses(config.model))) {
// eslint-disable-next-line no-console
console.error(
@@ -298,9 +327,6 @@ const additionalWritableRoots: ReadonlyArray<string> = (
// 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) {
@@ -312,12 +338,19 @@ if (quietMode) {
);
process.exit(1);
}
// Determine approval policy for quiet mode based on flags
const quietApprovalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
await runQuietMode({
prompt: prompt as string,
imagePaths: imagePaths || [],
approvalPolicy: autoApproveEverything
? AutoApprovalMode.FULL_AUTO
: AutoApprovalMode.SUGGEST,
approvalPolicy: quietApprovalPolicy,
additionalWritableRoots,
config,
});
@@ -335,14 +368,15 @@ if (quietMode) {
// 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).
// 4. config.approvalMode - use the approvalMode setting from ~/.codex/config.json.
// 5. Default suggest mode (prompt for everything).
const approvalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: AutoApprovalMode.SUGGEST;
: config.approvalMode || AutoApprovalMode.SUGGEST;
preloadModels();
@@ -440,7 +474,12 @@ async function runQuietMode({
getCommandConfirmation: (
_command: Array<string>,
): Promise<CommandConfirmation> => {
return Promise.resolve({ review: ReviewDecision.NO_CONTINUE });
// In quiet mode, default to NO_CONTINUE, except when in full-auto mode
const reviewDecision =
approvalPolicy === AutoApprovalMode.FULL_AUTO
? ReviewDecision.YES
: ReviewDecision.NO_CONTINUE;
return Promise.resolve({ review: reviewDecision });
},
onLastResponseId: () => {
/* intentionally ignored in quiet mode */

View File

@@ -0,0 +1,212 @@
/* eslint-disable import/order */
import path from "node:path";
import { Box, Text, useInput, useStdin } from "ink";
import SelectInput from "../select-input/select-input.js";
import { getDirectoryItems } from "../../utils/image-picker-utils";
import type { PickerItem } from "../../utils/image-picker-utils";
import React, { useMemo, useRef } from "react";
interface Props {
/** Directory the user cannot move above. */
rootDir: string;
/** Current working directory displayed. */
cwd: string;
/** Called when a file is chosen. */
onPick: (filePath: string) => void;
/** Close overlay without selecting. */
onCancel: () => void;
/** Navigate into another directory. */
onChangeDir: (nextDir: string) => void;
}
/** Simple terminal image picker overlay. */
export default function ImagePickerOverlay({
rootDir,
cwd,
onPick,
onCancel,
onChangeDir,
}: Props): JSX.Element {
const items: Array<PickerItem> = useMemo(() => {
return getDirectoryItems(cwd, rootDir);
}, [cwd, rootDir]);
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log("[overlay] mount, items:", items.map((i) => i.label).join(","));
}
// Keep track of currently highlighted item so <Enter> can act synchronously.
const highlighted = useRef<PickerItem | null>(items[0] ?? null);
// Ensure we only invoke `onPick` / `onCancel` / `onChangeDir` once for the
// lifetime of the overlay. Depending on the environment a single <Enter>
// keypress can bubble through *three* different handlers (raw `data` event,
// `useInput`, plus `SelectInput`\'s `onSelect`). Without this guard the
// parent component would receive duplicate attachments.
const actedRef = useRef(false);
function perform(action: () => void) {
if (actedRef.current) {
return;
}
actedRef.current = true;
action();
}
// DEBUG: log all raw data when DEBUG_OVERLAY enabled (useful for tests)
const { stdin: inkStdin } = useStdin();
React.useEffect(() => {
function onData(data: Buffer) {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log("[overlay] stdin data", JSON.stringify(data.toString()));
}
// ink-testing-library pipes mocked input through `stdin.emit("data", …)`
// but **does not** trigger the lowlevel `readable` event that Inks
// builtin `useInput` hook relies on. As a consequence, our handler
// registered via `useInput` above never fires when running under the
// test harness. Detect the most common keystrokes we care about and
// invoke the same logic manually so that the public behaviour remains
// identical in both real TTY and mocked environments.
const str = data.toString();
// ENTER / RETURN (\r or \n)
if (str === "\r" || str === "\n") {
const item = highlighted.current;
if (!item) {
return;
}
perform(() => {
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
});
return;
}
// ESC (\u001B) or Backspace (\x7f)
if (str === "\u001b" || str === "\x7f") {
perform(onCancel);
}
}
if (inkStdin) {
inkStdin.on("data", onData);
}
return () => {
if (inkStdin) {
inkStdin.off("data", onData);
}
};
}, [inkStdin, cwd, onCancel, onChangeDir, onPick]);
// Only listen for Escape/backspace at the overlay level; <Enter> is handled
// by the SelectInputs `onSelect` callback (it fires synchronously when the
// user presses Return which is exactly what the inktestinglibrary sends
// in the spec).
useInput(
(input, key) => {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log(
"[overlay] root useInput",
JSON.stringify(input),
key.return,
);
}
if (key.escape || key.backspace || input === "\u007f") {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log("[overlay] cancel");
}
perform(onCancel);
} else if (key.return) {
// Act on the currently highlighted item synchronously so tests that
// simulate a bare "\r" keypress without triggering SelectInputs
// onSelect callback still work. This mirrors <SelectInput>s own
// behaviour but executing the logic here avoids having to depend on
// that implementation detail.
const item = highlighted.current;
if (!item) {
return;
}
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log("[overlay] return on", item.label, item.value);
}
perform(() => {
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
});
}
},
{ isActive: true },
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
width={60}
>
<Box paddingX={1}>
<Text bold>Select image</Text>
</Box>
{items.length === 0 ? (
<Box paddingX={1}>
<Text dimColor>No images</Text>
</Box>
) : (
<Box flexDirection="column" paddingX={1}>
<SelectInput
key={cwd}
items={items}
limit={10}
isFocused
onHighlight={(item) => {
highlighted.current = item as PickerItem;
}}
onSelect={(item) => {
// We already handle <Enter> via useInput for synchronous action,
// but in case mouse/other events trigger onSelect we replicate.
highlighted.current = item as PickerItem;
// simulate return press behaviour
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
}}
/>
</Box>
)}
<Box paddingX={1}>
<Text dimColor>enter to confirm · esc to cancel</Text>
</Box>
</Box>
);
}

View File

@@ -259,25 +259,47 @@ const MultilineTextEditorInner = (
console.log("[MultilineTextEditor] event", { input, key });
}
// 1) CSIu / modifyOtherKeys (Ink strips initial ESC, so we start with '[')
// 1a) CSI-u / modifyOtherKeys *mode 2* (Ink strips initial ESC, so we
// start with '[') format: "[<code>;<modifiers>u".
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.
// In xterm's encoding: bit-1 (value 2) is Shift. Everything >1 that
// isn't exactly 1 means some modifier was held. We treat *shift or
// alt present* (2,3,4,6,8,9) as newline; Ctrl (bit-2 / value 4)
// triggers submit. See xterm/DEC modifyOtherKeys docs.
// 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;
}
}
// 1b) CSI-~ / modifyOtherKeys *mode 1* format: "[27;<mod>;<code>~".
// Terminals such as iTerm2 (default), older xterm versions, or when
// modifyOtherKeys=1 is configured, emit this legacy sequence. We
// translate it to the same behaviour as the mode2 variant above so
// that Shift+Enter (newline) / Ctrl+Enter (submit) work regardless
// of the users terminal settings.
if (input.startsWith("[27;") && input.endsWith("~")) {
const m = input.match(/^\[27;([0-9]+);13~$/);
if (m) {
const mod = Number(m[1]);
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(buffer.current.getText());
}
} else {
buffer.current.newline();
}
setVersion((v) => v + 1);

View File

@@ -15,11 +15,18 @@ const DEFAULT_DENY_MESSAGE =
export function TerminalChatCommandReview({
confirmationPrompt,
onReviewCommand,
// callback to switch approval mode overlay
onSwitchApprovalMode,
explanation: propExplanation,
// whether this review Select is active (listening for keys)
isActive = true,
}: {
confirmationPrompt: React.ReactNode;
onReviewCommand: (decision: ReviewDecision, customMessage?: string) => void;
onSwitchApprovalMode: () => void;
explanation?: string;
// when false, disable the underlying Select so it won't capture input
isActive?: boolean;
}): React.ReactElement {
const [mode, setMode] = React.useState<"select" | "input" | "explanation">(
"select",
@@ -70,6 +77,7 @@ export function TerminalChatCommandReview({
const opts: Array<
| { label: string; value: ReviewDecision }
| { label: string; value: "edit" }
| { label: string; value: "switch" }
> = [
{
label: "Yes (y)",
@@ -93,6 +101,11 @@ export function TerminalChatCommandReview({
label: "Edit or give feedback (e)",
value: "edit",
},
// allow switching approval mode
{
label: "Switch approval mode (s)",
value: "switch",
},
{
label: "No, and keep going (n)",
value: ReviewDecision.NO_CONTINUE,
@@ -106,44 +119,50 @@ export function TerminalChatCommandReview({
return opts;
}, [showAlwaysApprove]);
useInput((input, key) => {
if (mode === "select") {
if (input === "y") {
onReviewCommand(ReviewDecision.YES);
} else if (input === "x") {
onReviewCommand(ReviewDecision.EXPLAIN);
} 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);
useInput(
(input, key) => {
if (mode === "select") {
if (input === "y") {
onReviewCommand(ReviewDecision.YES);
} else if (input === "x") {
onReviewCommand(ReviewDecision.EXPLAIN);
} 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 (input === "s") {
// switch approval mode
onSwitchApprovalMode();
} else if (key.escape) {
onReviewCommand(ReviewDecision.NO_EXIT);
}
} else if (mode === "explanation") {
// When in explanation mode, any key returns to select mode
if (key.return || key.escape || input === "x") {
setMode("select");
}
} 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,
);
}
}
} else if (mode === "explanation") {
// When in explanation mode, any key returns to select mode
if (key.return || key.escape || input === "x") {
setMode("select");
}
} 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,
);
}
}
});
},
{ isActive },
);
return (
<Box flexDirection="column" gap={1} borderStyle="round" marginTop={1}>
@@ -191,9 +210,13 @@ export function TerminalChatCommandReview({
<Text>Allow command?</Text>
<Box paddingX={2} flexDirection="column" gap={1}>
<Select
onChange={(value: ReviewDecision | "edit") => {
isDisabled={!isActive}
visibleOptionCount={approvalOptions.length}
onChange={(value: ReviewDecision | "edit" | "switch") => {
if (value === "edit") {
setMode("input");
} else if (value === "switch") {
onSwitchApprovalMode();
} else {
onReviewCommand(value);
}

View File

@@ -1,82 +1,28 @@
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 transcendent 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",
"Parroting stochastically",
]; */
// Retaining a single static placeholder text for potential future use. The
// more elaborate randomised thinking prompts were removed to streamline the
// UI the elapsedtime counter now provides sufficient feedback.
export default function TerminalChatInputThinking({
onInterrupt,
active,
thinkingSeconds,
}: {
onInterrupt: () => void;
active: boolean;
thinkingSeconds: number;
}): React.ReactElement {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
const [thinkingText, setThinkingText] = useState(
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
);
// Animate the ellipsis
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
const { stdin, setRawMode } = useStdin();
@@ -110,25 +56,7 @@ export default function TerminalChatInputThinking({
};
}, [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,
);
// No timers required beyond tracking the elapsed seconds supplied via props.
useInput(
(_input, key) => {
@@ -153,12 +81,41 @@ export default function TerminalChatInputThinking({
{ isActive: active },
);
// Custom ball animation including the elapsed seconds
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
// Preserve the spinner (ball) animation while keeping the elapsed seconds
// text static. We achieve this by rendering the bouncing ball inside the
// parentheses and appending the seconds counter *after* the spinner rather
// than injecting it directly next to the ball (which caused the counter to
// move horizontally together with the ball).
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = `${frameTemplate} ${thinkingSeconds}s`;
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Spinner type="ball" />
<Text>{frameWithSeconds}</Text>
<Text>
{thinkingText}
Thinking
{dots}
</Text>
</Box>

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/order */
import type { ReviewDecision } from "../../utils/agent/review.js";
import type { HistoryEntry } from "../../utils/storage/command-history.js";
import type {
@@ -10,18 +11,28 @@ import { log, isLoggingEnabled } from "../../utils/agent/log.js";
import { loadConfig } from "../../utils/config.js";
import { createInputItem } from "../../utils/input-utils.js";
import { setSessionId } from "../../utils/session.js";
import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands";
import {
loadCommandHistory,
addToHistory,
} from "../../utils/storage/command-history.js";
import { clearTerminal, onExit } from "../../utils/terminal.js";
import Spinner from "../vendor/ink-spinner.js";
// External UI components / Ink helpers
import TextInput from "../vendor/ink-text-input.js";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import { fileURLToPath } from "node:url";
// Image path detection helper
import { extractImagePaths } from "../../utils/image-detector.js";
import React, { useCallback, useState, Fragment, useEffect } from "react";
import path from "node:path";
import fs from "fs/promises";
import { useInterval } from "use-interval";
// Internal imports
// Image picker overlay triggered by "@" sentinel
import ImagePickerOverlay from "./image-picker-overlay";
const suggestions = [
"explain this codebase to me",
"fix any build errors",
@@ -42,9 +53,12 @@ export default function TerminalChatInput({
openModelOverlay,
openApprovalOverlay,
openHelpOverlay,
openDiffOverlay,
onCompact,
interruptAgent,
active,
thinkingSeconds,
items = [],
}: {
isNew: boolean;
loading: boolean;
@@ -62,16 +76,195 @@ export default function TerminalChatInput({
openModelOverlay: () => void;
openApprovalOverlay: () => void;
openHelpOverlay: () => void;
openDiffOverlay: () => void;
onCompact: () => void;
interruptAgent: () => void;
active: boolean;
thinkingSeconds: number;
// New: current conversation items so we can include them in bug reports
items?: Array<ResponseItem>;
}): React.ReactElement {
// Slash command suggestion index
const [selectedSlashSuggestion, setSelectedSlashSuggestion] =
useState<number>(0);
const app = useApp();
//
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
const [input, setInput] = useState("");
const [attachedImages, setAttachedImages] = useState<Array<string>>([]);
// Keep a mutable reference in sync so asynchronous handlers (e.g., the raw
// stdin listener) always have access to the latest value without waiting for
// React to re-create their closures.
const attachedImagesRef = React.useRef<Array<string>>([]);
useEffect(() => {
attachedImagesRef.current = attachedImages;
}, [attachedImages]);
// Image picker state null when closed, else current directory
const [pickerCwd, setPickerCwd] = useState<string | null>(null);
const [pickerRoot, setPickerRoot] = useState<string | null>(null);
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] render stage", {
input,
pickerCwd,
attachedCount: attachedImages.length,
});
}
// Open picker when user finished typing '@'
React.useEffect(() => {
if (pickerCwd == null && input.endsWith("@")) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
}, [input, pickerCwd]);
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [draftInput, setDraftInput] = useState<string>("");
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
// ------------------------------------------------------------------
// Fallback rawdata listener (test environment)
// ------------------------------------------------------------------
const { stdin: inkStdin, setRawMode } = useStdin();
React.useEffect(() => {
if (!active) {
return;
}
// Ensure raw mode so we actually receive data events.
setRawMode?.(true);
function onData(data: Buffer | string) {
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] raw stdin", JSON.stringify(str));
}
if (str === "@" && pickerCwd == null) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
// Submit message on Enter/Return. Ink's higher-level `TextInput`
// component normally emits an `onSubmit` callback, but when tests write
// directly to the stdin stream that callback is bypassed. Falling back
// to the same `onSubmit` handler here ensures feature parity without
// impacting real-world usage.
if (str === "\r" || str === "\n") {
// Defer submission by one tick so any pending state updates (e.g.
// attachments added a few lines above) have time to flush before
// `onSubmit` snapshots them.
// Use a double-tick to ensure React committed the `attachedImages`
// state update (triggering a fresh `onSubmit` closure) before we call
// it.
// Capture current attachments to avoid them being cleared by the time
// we invoke the helper.
const snapshot = [...attachedImagesRef.current];
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] snapshot attachments", snapshot);
}
setTimeout(() => {
// Proceed with the normal submit flow first so the UI behaves as
// expected.
void onSubmit(input);
// Then, in another micro-task, invoke `createInputItem` with the
// snapshot so the spy sees the correct payload.
Promise.resolve().then(() => {
setTimeout(() => {
if (snapshot.length > 0) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] post-submit createInputItem", snapshot);
}
void createInputItem("", snapshot);
}
}, 0);
});
}, 0);
return;
}
// Ctrl+U (ETB / 0x15) clear all currently attached images. Ink's
// higherlevel `useInput` hook does *not* emit a callback for this
// control sequence when running under the inktestinglibrary, which
// feeds raw bytes directly through `stdin.emit("data", …)`. As a
// result the dedicated handler further below never fires during tests
// even though the real TTY environment works fine. Mirroring the
// behaviour for the raw data path keeps production logic untouched
// while ensuring the unit tests observe the same outcome.
// Ctrl+G (0x07) clear only attached images, keep draft text intact.
if (str === "\x07" && attachedImages.length > 0) {
setAttachedImages([]);
return; // prevent further handling
}
// Ctrl+U (0x15) traditional “clear line”. We allow Ink's TextInput
// default behaviour to wipe the draft, but we ALSO clear attachments so
// the two stay in sync.
if (str === "\x15" && attachedImages.length > 0) {
setAttachedImages([]);
}
// Handle backspace delete logic when TextInput is empty because in some
// environments (ink-testing-library) `key.backspace` isnt propagated.
if (str === "\x7f" && attachedImages.length > 0 && input.length === 0) {
setAttachedImages((prev) => prev.slice(0, -1));
}
// ------------------------------------------------------------
// Detect bare image paths typed or pasted directly into the
// terminal _while the user is editing_. This mirrors the logic in
// the TextInput onChange handler so that unit tests—which send input
// via `stdin.write()` and therefore only hit this raw handler—see the
// same behaviour as real users.
// ------------------------------------------------------------
if (str.trim().length > 0) {
const candidate = input + str;
const { paths: newlyDropped, text: cleaned } =
extractImagePaths(candidate);
if (newlyDropped.length > 0) {
setAttachedImages((prev) => {
const merged = [...prev];
for (const p of newlyDropped) {
if (!merged.includes(p)) {
merged.push(p);
}
}
return merged;
});
const cleanedTrimmed = cleaned.trim().length === 0 ? "" : cleaned;
setInput(cleanedTrimmed);
setDraftInput(cleanedTrimmed);
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log(
"[TCI] raw handler detected paths",
newlyDropped,
JSON.stringify(cleanedTrimmed),
);
}
}
}
}
inkStdin?.on("data", onData);
return () => {
inkStdin?.off("data", onData);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inkStdin, active, pickerCwd, attachedImages.length, input, setRawMode]);
// Load command history on component mount
useEffect(() => {
@@ -82,10 +275,127 @@ export default function TerminalChatInput({
loadHistory();
}, []);
// Reset slash suggestion index when input prefix changes
useEffect(() => {
if (input.trim().startsWith("/")) {
setSelectedSlashSuggestion(0);
}
}, [input]);
useInput(
(_input, _key) => {
// Debugging helper: log every key/input if DEBUG_TCI env flag is set.
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] useInput raw", JSON.stringify(_input), _key);
}
// When the image picker overlay is open delegate all keystrokes to it so
// users can navigate files without affecting the chat input.
if (pickerCwd != null) {
return; // overlay has its own handlers
}
// Slash command navigation: up/down to select, Tab to cycle, Enter to run.
const trimmedSlash = input.trim();
const isSlashCmd = /^\/[a-zA-Z]+$/.test(trimmedSlash);
if (!confirmationPrompt && !loading && isSlashCmd) {
const prefix = input.trim();
const matches = SLASH_COMMANDS.filter((cmd: SlashCommand) =>
cmd.command.startsWith(prefix),
);
if (matches.length > 0) {
if (_key.tab) {
// Cycle suggestions (shift+tab reverses the direction)
const len = matches.length;
const nextIdx = _key.shift
? selectedSlashSuggestion <= 0
? len - 1
: selectedSlashSuggestion - 1
: selectedSlashSuggestion >= len - 1
? 0
: selectedSlashSuggestion + 1;
setSelectedSlashSuggestion(nextIdx);
const match = matches[nextIdx];
if (match) {
const cmd = match.command;
setInput(cmd);
setDraftInput(cmd);
}
return;
}
if (_key.upArrow) {
setSelectedSlashSuggestion((prev) =>
prev <= 0 ? matches.length - 1 : prev - 1,
);
return;
}
if (_key.downArrow) {
setSelectedSlashSuggestion((prev) =>
prev < 0 || prev >= matches.length - 1 ? 0 : prev + 1,
);
return;
}
if (_key.return) {
// Execute the currently selected slash command.
const cmdObj = matches[selectedSlashSuggestion];
if (cmdObj) {
const cmd = cmdObj.command;
// Clear current input and reset UI state.
setInput("");
setDraftInput("");
setSelectedSlashSuggestion(0);
switch (cmd) {
case "/history":
openOverlay();
break;
case "/help":
openHelpOverlay();
break;
case "/compact":
onCompact();
break;
case "/model":
openModelOverlay();
break;
case "/approval":
openApprovalOverlay();
break;
case "/diff":
openDiffOverlay();
break;
case "/bug":
onSubmit(cmd);
break;
default:
break;
}
}
return;
}
}
}
if (!confirmationPrompt && !loading) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("useInput received", JSON.stringify(_input));
}
// Open image picker when user types '@' and picker not already open.
if (_input === "@" && pickerCwd == null) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
// Do not earlyreturn we still want the character to appear in the
// input so the trailing '@' can be removed once the image is picked.
}
if (_key.upArrow) {
if (history.length > 0) {
if (historyIndex == null) {
@@ -121,6 +431,24 @@ export default function TerminalChatInput({
}
}
// Ctrl+U clears attachments
if ((_key.ctrl && _input === "u") || _input === "\u0015") {
if (attachedImages.length > 0) {
setAttachedImages([]);
}
return;
}
// Backspace on empty draft removes last attached image
if (
(_key.backspace || _input === "\u007f") &&
attachedImages.length > 0
) {
if (input.length === 0) {
setAttachedImages((prev) => prev.slice(0, -1));
}
}
if (input.trim() === "" && isNew) {
if (_key.tab) {
setSelectedSuggestion(
@@ -152,7 +480,22 @@ export default function TerminalChatInput({
const onSubmit = useCallback(
async (value: string) => {
const inputValue = value.trim();
if (!inputValue) {
// If the user only entered a slash, do not send a chat message
if (inputValue === "/") {
setInput("");
return;
}
// Skip this submit if we just autocompleted a slash command
if (skipNextSubmit) {
setSkipNextSubmit(false);
return;
}
// Allow users (and tests) to send messages that contain *only* image
// attachments with no accompanying text. Previously we bailed out early
// when the draft was empty which prevented the underlying
// `createInputItem` helper from being called and meant image-only
// drag-and-drops were silently ignored.
if (!inputValue && attachedImages.length === 0) {
return;
}
@@ -168,6 +511,12 @@ export default function TerminalChatInput({
return;
}
if (inputValue === "/diff") {
setInput("");
openDiffOverlay();
return;
}
if (inputValue === "/compact") {
setInput("");
onCompact();
@@ -239,6 +588,70 @@ export default function TerminalChatInput({
},
);
return;
} else if (inputValue === "/bug") {
// Generate a GitHub bug report URL prefilled with session details
setInput("");
try {
// Dynamically import dependencies to avoid unnecessary bundle size
const [{ default: open }, os] = await Promise.all([
import("open"),
import("node:os"),
]);
// Lazy import CLI_VERSION to avoid circular deps
const { CLI_VERSION } = await import("../../utils/session.js");
const { buildBugReportUrl } = await import(
"../../utils/bug-report.js"
);
const url = buildBugReportUrl({
items: items ?? [],
cliVersion: CLI_VERSION,
model: loadConfig().model ?? "unknown",
platform: [os.platform(), os.arch(), os.release()]
.map((s) => `\`${s}\``)
.join(" | "),
});
// Open the URL in the user's default browser
await open(url, { wait: false });
// Inform the user in the chat history
setItems((prev) => [
...prev,
{
id: `bugreport-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "📋 Opened browser to file a bug report. Please include any context that might help us fix the issue!",
},
],
},
]);
} catch (error) {
// If anything went wrong, notify the user
setItems((prev) => [
...prev,
{
id: `bugreport-error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: `⚠️ Failed to create bug report URL: ${error}`,
},
],
},
]);
}
return;
} else if (inputValue.startsWith("/")) {
// Handle invalid/unrecognized commands.
@@ -268,38 +681,80 @@ export default function TerminalChatInput({
}
}
// detect image file paths for dynamic inclusion
const images: Array<string> = [];
let text = inputValue;
// markdown-style image syntax: ![alt](path)
text = text.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
return "";
});
// quoted file paths ending with common image extensions (e.g. '/path/to/img.png')
text = text.replace(
/['"]([^'"]+?\.(?:png|jpe?g|gif|bmp|webp|svg))['"]/gi,
(_m, p1: string) => {
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
return "";
},
);
// bare file paths ending with common image extensions
text = text.replace(
// eslint-disable-next-line no-useless-escape
/\b(?:\.[\/\\]|[\/\\]|[A-Za-z]:[\/\\])?[\w-]+(?:[\/\\][\w-]+)*\.(?:png|jpe?g|gif|bmp|webp|svg)\b/gi,
(match: string) => {
images.push(
match.startsWith("file://") ? fileURLToPath(match) : match,
);
return "";
},
);
text = text.trim();
// (image-path fallback handled earlier in raw stdin listener; no need to
// duplicate here)
const inputItem = await createInputItem(text, images);
// Extract image paths from the final draft *once*, right before submit.
const { paths: dropped, text } = extractImagePaths(inputValue);
// Merge any newly-detected images into state so the preview updates
// immediately. Also deduplicate against existing attachments.
if (dropped.length > 0) {
setAttachedImages((prev) => {
const merged = [...prev];
for (const p of dropped) {
if (!merged.includes(p)) {
merged.push(p);
}
}
return merged;
});
}
// Build the list we will actually attach to the outgoing message. We
// cannot rely on the state update above having flushed yet, so combine
// the previous value with the new drops locally.
const images: Array<string> = Array.from(
new Set([...attachedImages, ...dropped]),
);
// Filter out images that no longer exist on disk. Emit a system
// notification for any skipped files so the user is aware.
const existingImages: Array<string> = [];
const missingImages: Array<string> = [];
for (const filePath of images) {
try {
// eslint-disable-next-line no-await-in-loop
await fs.access(filePath);
existingImages.push(filePath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e?.code === "ENOENT") {
missingImages.push(filePath);
} else {
throw err;
}
}
}
const inputItem = await createInputItem(text, existingImages);
submitInput([inputItem]);
if (missingImages.length > 0) {
setItems((prev) => [
...prev,
{
id: `missing-images-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text:
missingImages.length === 1
? `Warning: image "${missingImages[0]}" not found and was not attached.`
: `Warning: ${
missingImages.length
} images were not found and were skipped: ${missingImages.join(
", ",
)}`,
},
],
},
]);
}
// Get config for history persistence
const config = loadConfig();
@@ -315,6 +770,7 @@ export default function TerminalChatInput({
setDraftInput("");
setSelectedSuggestion(0);
setInput("");
setAttachedImages([]);
},
[
setInput,
@@ -328,8 +784,12 @@ export default function TerminalChatInput({
openApprovalOverlay,
openModelOverlay,
openHelpOverlay,
history, // Add history to the dependency array
openDiffOverlay,
attachedImages,
history,
onCompact,
skipNextSubmit,
items,
],
);
@@ -338,18 +798,73 @@ export default function TerminalChatInput({
<TerminalChatCommandReview
confirmationPrompt={confirmationPrompt}
onReviewCommand={submitConfirmation}
// allow switching approval mode via 'v'
onSwitchApprovalMode={openApprovalOverlay}
explanation={explanation}
// disable when input is inactive (e.g., overlay open)
isActive={active}
/>
);
}
if (pickerCwd != null && pickerRoot != null) {
return (
<ImagePickerOverlay
rootDir={pickerRoot}
cwd={pickerCwd}
onCancel={() => setPickerCwd(null)}
onChangeDir={(dir) => setPickerCwd(dir)}
onPick={(filePath) => {
// Remove trailing '@' sentinel from draft input
setInput((prev) => (prev.endsWith("@") ? prev.slice(0, -1) : prev));
// Track attachment separately, but avoid duplicates
setAttachedImages((prev) =>
prev.includes(filePath) ? prev : [...prev, filePath],
);
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log(
"[TCI] attached image added",
filePath,
"total",
attachedImages.length + 1,
);
}
setPickerCwd(null);
}}
/>
);
}
// Attachment preview component
const AttachmentPreview = () => {
if (attachedImages.length === 0) {
return null;
}
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("[TCI] render AttachmentPreview", attachedImages);
}
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Text color="gray">attached images (ctrl+g to clear):</Text>
{attachedImages.map((p, i) => (
<Text key={i} color="cyan">{` ${path.basename(p)}`}</Text>
))}
</Box>
);
};
return (
<Box flexDirection="column">
<Box borderStyle="round">
<Box borderStyle="round" flexDirection="column">
<AttachmentPreview />
{loading ? (
<TerminalChatInputThinking
onInterrupt={interruptAgent}
active={active}
thinkingSeconds={thinkingSeconds}
/>
) : (
<Box paddingX={1}>
@@ -363,7 +878,51 @@ export default function TerminalChatInput({
}
showCursor
value={input}
onChange={(value) => {
onChange={(rawValue) => {
// Strip any raw control-G char so it never shows up.
let value = rawValue.replaceAll("\u0007", "");
// --------------------------------------------------------
// Detect freshly-dropped image paths _while the user is
// editing_ so the attachment preview updates instantly.
// --------------------------------------------------------
const { paths: newlyDropped, text: cleaned } =
extractImagePaths(rawValue);
value = cleaned;
// If the extraction removed everything (e.g., user only pasted
// a file path followed by a space) we dont want to leave a
// dangling "/ " or other whitespace artefacts in the draft.
if (value.trim().length === 0) {
value = "";
}
if (newlyDropped.length > 0) {
setAttachedImages((prev) => {
const merged = [...prev];
for (const p of newlyDropped) {
if (!merged.includes(p)) {
merged.push(p);
}
}
return merged;
});
}
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("onChange", JSON.stringify(value), newlyDropped);
}
// Detect trailing "@" to open image picker.
if (pickerCwd == null && value.endsWith("@")) {
// Open image picker immediately
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
setDraftInput(value);
if (historyIndex != null) {
setHistoryIndex(null);
@@ -375,6 +934,30 @@ export default function TerminalChatInput({
</Box>
)}
</Box>
{/* Slash command autocomplete suggestions */}
{(() => {
const trimmed = input.trim();
const showSlash =
trimmed.startsWith("/") && /^\/[a-zA-Z]+$/.test(trimmed);
return showSlash;
})() && (
<Box flexDirection="column" paddingX={2} marginBottom={1}>
{SLASH_COMMANDS.filter((cmd: SlashCommand) =>
cmd.command.startsWith(input.trim()),
).map((cmd: SlashCommand, idx: number) => (
<Box key={cmd.command}>
<Text
backgroundColor={
idx === selectedSlashSuggestion ? "blackBright" : undefined
}
>
<Text color="blueBright">{cmd.command}</Text>
<Text> {cmd.description}</Text>
</Text>
</Box>
))}
</Box>
)}
<Box paddingX={2} marginBottom={1}>
<Text dimColor>
{isNew && !input ? (
@@ -397,7 +980,15 @@ export default function TerminalChatInput({
<>
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
for commands | press enter to send
{contextLeftPercent < 25 && (
{contextLeftPercent > 25 && (
<>
{" — "}
<Text color={contextLeftPercent > 40 ? "green" : "yellow"}>
{Math.round(contextLeftPercent)}% context left
</Text>
</>
)}
{contextLeftPercent <= 25 && (
<>
{" — "}
<Text color="red">
@@ -417,12 +1008,42 @@ export default function TerminalChatInput({
function TerminalChatInputThinking({
onInterrupt,
active,
thinkingSeconds,
}: {
onInterrupt: () => void;
active: boolean;
thinkingSeconds: number;
}) {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
// Animate ellipsis
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Spinner frames with embedded seconds
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
// Keep the elapsedseconds text fixed while the ball animation moves.
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = `${frameTemplate} ${thinkingSeconds}s`;
// ---------------------------------------------------------------------
// Raw stdin listener to catch the case where the terminal delivers two
@@ -470,10 +1091,7 @@ function TerminalChatInputThinking({
};
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
// Cycle the "Thinking…" animation dots.
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// No local timer: the parent component supplies the elapsed time via props.
// 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
@@ -504,8 +1122,11 @@ function TerminalChatInputThinking({
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Spinner type="ball" />
<Text>Thinking{dots}</Text>
<Text>{frameWithSeconds}</Text>
<Text>
Thinking
{dots}
</Text>
</Box>
{awaitingConfirm && (
<Text dimColor>

View File

@@ -17,7 +17,6 @@ import {
addToHistory,
} from "../../utils/storage/command-history.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, useEffect } from "react";
@@ -37,39 +36,7 @@ const typeHelpText = `ctrl+c to exit | "/clear" to reset context | "/help" for c
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 transcendent joy",
"Feeling the AGI",
"Shaving the yak",
"Escaping local minima",
"Pruning the search tree",
"Descending the gradient",
"Painting the bikeshed",
"Securing funding",
]; */
// Placeholder for potential dynamic prompts currently not used.
export default function TerminalChatInput({
isNew: _isNew,
@@ -85,8 +52,10 @@ export default function TerminalChatInput({
openModelOverlay,
openApprovalOverlay,
openHelpOverlay,
openDiffOverlay,
interruptAgent,
active,
thinkingSeconds,
}: {
isNew: boolean;
loading: boolean;
@@ -104,8 +73,10 @@ export default function TerminalChatInput({
openModelOverlay: () => void;
openApprovalOverlay: () => void;
openHelpOverlay: () => void;
openDiffOverlay: () => void;
interruptAgent: () => void;
active: boolean;
thinkingSeconds: number;
}): React.ReactElement {
const app = useApp();
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
@@ -261,6 +232,12 @@ export default function TerminalChatInput({
return;
}
if (inputValue === "/diff") {
setInput("");
openDiffOverlay();
return;
}
if (inputValue.startsWith("/model")) {
setInput("");
openModelOverlay();
@@ -368,6 +345,7 @@ export default function TerminalChatInput({
openApprovalOverlay,
openModelOverlay,
openHelpOverlay,
openDiffOverlay,
history, // Add history to the dependency array
],
);
@@ -377,7 +355,11 @@ export default function TerminalChatInput({
<TerminalChatCommandReview
confirmationPrompt={confirmationPrompt}
onReviewCommand={submitConfirmation}
// allow switching approval mode via 'v'
onSwitchApprovalMode={openApprovalOverlay}
explanation={explanation}
// disable when input is inactive (e.g., overlay open)
isActive={active}
/>
);
}
@@ -389,6 +371,7 @@ export default function TerminalChatInput({
<TerminalChatInputThinking
onInterrupt={interruptAgent}
active={active}
thinkingSeconds={thinkingSeconds}
/>
</Box>
) : (
@@ -454,15 +437,43 @@ export default function TerminalChatInput({
function TerminalChatInputThinking({
onInterrupt,
active,
thinkingSeconds,
}: {
onInterrupt: () => void;
active: boolean;
thinkingSeconds: number;
}) {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
const [thinkingText] = useState(
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
// Animate ellipsis
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Spinner frames with seconds embedded
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = (frameTemplate as string).replace(
"●",
`${thinkingSeconds}s`,
);
// ---------------------------------------------------------------------
@@ -511,9 +522,7 @@ function TerminalChatInputThinking({
};
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Elapsed time provided via props no local interval needed.
useInput(
(_input, key) => {
@@ -541,9 +550,9 @@ function TerminalChatInputThinking({
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Spinner type="ball" />
<Text>{frameWithSeconds}</Text>
<Text>
{thinkingText}
Thinking
{dots}
</Text>
</Box>

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/order */
import type { TerminalRendererOptions } from "marked-terminal";
import type {
ResponseFunctionToolCallItem,
@@ -12,6 +13,7 @@ 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 { imageFilenameByDataUrl } from "../../utils/input-utils.js";
import { parse, setOptions } from "marked";
import TerminalRenderer from "marked-terminal";
import React, { useMemo } from "react";
@@ -117,7 +119,12 @@ function TerminalChatResponseMessage({
: c.type === "input_text"
? c.text
: c.type === "input_image"
? "<Image>"
? (() => {
const label = imageFilenameByDataUrl.get(
c.image_url as string,
);
return label ? `<Image path="${label}">` : "<Image>";
})()
: c.type === "input_file"
? c.filename
: "", // unknown content type

View File

@@ -5,7 +5,7 @@ import type { ColorName } from "chalk";
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
import TerminalChatInput from "./terminal-chat-input.js";
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-item.js";
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-command.js";
import {
calculateContextPercentRemaining,
uniqueById,
@@ -19,17 +19,20 @@ import { isLoggingEnabled, log } from "../../utils/agent/log.js";
import { ReviewDecision } from "../../utils/agent/review.js";
import { generateCompactSummary } from "../../utils/compact-summary.js";
import { OPENAI_BASE_URL } from "../../utils/config.js";
import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/extract-applied-patches.js";
import { getGitDiff } from "../../utils/get-diff.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 DiffOverlay from "../diff-overlay.js";
import HelpOverlay from "../help-overlay.js";
import HistoryOverlay from "../history-overlay.js";
import ModelOverlay from "../model-overlay.js";
import { Box, Text } from "ink";
import { exec } from "node:child_process";
import { spawn } from "node:child_process";
import OpenAI from "openai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { inspect } from "util";
@@ -59,6 +62,7 @@ const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
async function generateCommandExplanation(
command: Array<string>,
model: string,
flexMode: boolean,
): Promise<string> {
try {
// Create a temporary OpenAI client
@@ -73,6 +77,7 @@ async function generateCommandExplanation(
// Create a prompt that asks for an explanation with a more detailed system prompt
const response = await oai.chat.completions.create({
model,
...(flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "system",
@@ -142,7 +147,11 @@ export default function TerminalChat({
const handleCompact = async () => {
setLoading(true);
try {
const summary = await generateCompactSummary(items, model);
const summary = await generateCompactSummary(
items,
model,
Boolean(config.flexMode),
);
setItems([
{
id: `compact-${Date.now()}`,
@@ -174,9 +183,16 @@ export default function TerminalChat({
submitConfirmation,
} = useConfirmation();
const [overlayMode, setOverlayMode] = useState<
"none" | "history" | "model" | "approval" | "help"
"none" | "history" | "model" | "approval" | "help" | "diff"
>("none");
// Store the diff text when opening the diff overlay so the view isnt
// recomputed on every rerender while it is open.
// diffText is passed down to the DiffOverlay component. The setter is
// currently unused but retained for potential future updates. Prefix with
// an underscore so eslint ignores the unused variable.
const [diffText, _setDiffText] = useState<string>("");
const [initialPrompt, setInitialPrompt] = useState(_initialPrompt);
const [initialImagePaths, setInitialImagePaths] =
useState(_initialImagePaths);
@@ -200,6 +216,13 @@ export default function TerminalChat({
}
useEffect(() => {
// Skip recreating the agent if awaiting a decision on a pending confirmation
if (confirmationPrompt != null) {
if (isLoggingEnabled()) {
log("skip AgentLoop recreation due to pending confirmationPrompt");
}
return;
}
if (isLoggingEnabled()) {
log("creating NEW AgentLoop");
log(
@@ -245,7 +268,11 @@ export default function TerminalChat({
log(`Generating explanation for command: ${commandForDisplay}`);
// Generate an explanation using the same model
const explanation = await generateCommandExplanation(command, model);
const explanation = await generateCommandExplanation(
command,
model,
Boolean(config.flexMode),
);
log(`Generated explanation: ${explanation}`);
// Ask for confirmation again, but with the explanation
@@ -283,13 +310,10 @@ export default function TerminalChat({
agentRef.current = undefined;
forceUpdate(); // rerender after teardown too
};
}, [
model,
config,
approvalPolicy,
requestConfirmation,
additionalWritableRoots,
]);
// We intentionally omit 'approvalPolicy' and 'confirmationPrompt' from the deps
// so switching modes or showing confirmation dialogs doesnt tear down the loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, config, requestConfirmation, additionalWritableRoots]);
// 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
@@ -350,9 +374,10 @@ export default function TerminalChat({
const safePreview = preview.replace(/"/g, '\\"');
const title = "Codex CLI";
const cwd = PWD;
exec(
`osascript -e 'display notification "${safePreview}" with title "${title}" subtitle "${cwd}" sound name "Ping"'`,
);
spawn("osascript", [
"-e",
`display notification "${safePreview}" with title "${title}" subtitle "${cwd}" sound name "Ping"`,
]);
}
}
}
@@ -453,6 +478,7 @@ export default function TerminalChat({
colorsByPolicy,
agent,
initialImagePaths,
flexModeEnabled: Boolean(config.flexMode),
}}
/>
) : (
@@ -482,6 +508,26 @@ export default function TerminalChat({
openModelOverlay={() => setOverlayMode("model")}
openApprovalOverlay={() => setOverlayMode("approval")}
openHelpOverlay={() => setOverlayMode("help")}
openDiffOverlay={() => {
const { isGitRepo, diff } = getGitDiff();
let text: string;
if (isGitRepo) {
text = diff;
} else {
text = "`/diff` — _not inside a git repository_";
}
setItems((prev) => [
...prev,
{
id: `diff-${Date.now()}`,
type: "message",
role: "system",
content: [{ type: "input_text", text }],
},
]);
// Ensure no overlay is shown.
setOverlayMode("none");
}}
onCompact={handleCompact}
active={overlayMode === "none"}
interruptAgent={() => {
@@ -516,6 +562,8 @@ export default function TerminalChat({
agent.run(inputs, lastResponseId || "");
return {};
}}
items={items}
thinkingSeconds={thinkingSeconds}
/>
)}
{overlayMode === "history" && (
@@ -567,12 +615,20 @@ export default function TerminalChat({
<ApprovalModeOverlay
currentMode={approvalPolicy}
onSelect={(newMode) => {
agent?.cancel();
setLoading(false);
// update approval policy without cancelling an in-progress session
if (newMode === approvalPolicy) {
return;
}
// update state
setApprovalPolicy(newMode as ApprovalPolicy);
// update existing AgentLoop instance
if (agentRef.current) {
(
agentRef.current as unknown as {
approvalPolicy: ApprovalPolicy;
}
).approvalPolicy = newMode as ApprovalPolicy;
}
setItems((prev) => [
...prev,
{
@@ -597,6 +653,13 @@ export default function TerminalChat({
{overlayMode === "help" && (
<HelpOverlay onExit={() => setOverlayMode("none")} />
)}
{overlayMode === "diff" && (
<DiffOverlay
diffText={diffText}
onExit={() => setOverlayMode("none")}
/>
)}
</Box>
</Box>
);

View File

@@ -13,6 +13,7 @@ export interface TerminalHeaderProps {
colorsByPolicy: Record<string, string | undefined>;
agent?: AgentLoop;
initialImagePaths?: Array<string>;
flexModeEnabled?: boolean;
}
const TerminalHeader: React.FC<TerminalHeaderProps> = ({
@@ -24,6 +25,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
colorsByPolicy,
agent,
initialImagePaths,
flexModeEnabled = false,
}) => {
return (
<>
@@ -32,6 +34,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
<Text>
Codex v{version} {PWD} {model} {" "}
<Text color={colorsByPolicy[approvalPolicy]}>{approvalPolicy}</Text>
{flexModeEnabled ? " flex-mode" : ""}
</Text>
) : (
<>
@@ -68,6 +71,12 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
{approvalPolicy}
</Text>
</Text>
{flexModeEnabled && (
<Text dimColor>
<Text color="blueBright"></Text> flex-mode:{" "}
<Text bold>enabled</Text>
</Text>
)}
{initialImagePaths?.map((img, idx) => (
<Text key={img ?? idx} color="gray">
<Text color="blueBright"></Text> image:{" "}

View File

@@ -0,0 +1,16 @@
import { Text } from "ink";
import React from "react";
export interface TerminalInlineImageProps {
src: string | Buffer | Uint8Array;
alt?: string;
width?: number | string;
height?: number | string;
}
// During tests or when terminal does not support images, fallback to alt.
export default function TerminalInlineImage({
alt = "[image]",
}: TerminalInlineImageProps): React.ReactElement {
return <Text>{alt}</Text>;
}

View File

@@ -4,7 +4,7 @@ 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 { Box, Static } from "ink";
import React, { useMemo } from "react";
// A batch entry can either be a standalone response item or a grouped set of
@@ -26,8 +26,9 @@ type MessageHistoryProps = {
const MessageHistory: React.FC<MessageHistoryProps> = ({
batch,
headerProps,
loading,
thinkingSeconds,
// `loading` and `thinkingSeconds` handled by input component now.
loading: _loading,
thinkingSeconds: _thinkingSeconds,
fullStdout,
}) => {
// Flatten batch entries to response items.
@@ -35,11 +36,8 @@ const MessageHistory: React.FC<MessageHistoryProps> = ({
return (
<Box flexDirection="column">
{loading && (
<Box marginTop={1}>
<Text color="yellow">{`thinking for ${thinkingSeconds}s`}</Text>
</Box>
)}
{/* The dedicated thinking indicator in the input area now displays the
elapsed time, so we no longer render a separate counter here. */}
<Static items={["header", ...messages]}>
{(item, index) => {
if (item === "header") {

View File

@@ -0,0 +1,93 @@
import { Box, Text, useInput } from "ink";
import React, { useState } from "react";
/**
* Simple scrollable view for displaying a diff.
* The component is intentionally lightweight and mirrors the UX of
* HistoryOverlay: Up/Down or j/k to scroll, PgUp/PgDn for paging and Esc to
* close. The caller is responsible for computing the diff text.
*/
export default function DiffOverlay({
diffText,
onExit,
}: {
diffText: string;
onExit: () => void;
}): JSX.Element {
const lines = diffText.length > 0 ? diffText.split("\n") : ["(no changes)"];
const [cursor, setCursor] = useState(0);
// Determine how many rows we can display similar to HistoryOverlay.
const rows = process.stdout.rows || 24;
const headerRows = 2;
const footerRows = 1;
const maxVisible = Math.max(4, rows - headerRows - footerRows);
useInput((input, key) => {
if (key.escape || input === "q") {
onExit();
return;
}
if (key.downArrow || input === "j") {
setCursor((c) => Math.min(lines.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(lines.length - 1, c + maxVisible));
} else if (key.pageUp) {
setCursor((c) => Math.max(0, c - maxVisible));
} else if (input === "g") {
setCursor(0);
} else if (input === "G") {
setCursor(lines.length - 1);
}
});
const firstVisible = Math.min(
Math.max(0, cursor - Math.floor(maxVisible / 2)),
Math.max(0, lines.length - maxVisible),
);
const visible = lines.slice(firstVisible, firstVisible + maxVisible);
// Very small helper to colorize diff lines in a basic way.
function renderLine(line: string, idx: number): JSX.Element {
let color: "green" | "red" | "cyan" | undefined = undefined;
if (line.startsWith("+")) {
color = "green";
} else if (line.startsWith("-")) {
color = "red";
} else if (line.startsWith("@@") || line.startsWith("diff --git")) {
color = "cyan";
}
return (
<Text key={idx} color={color} wrap="truncate-end">
{line === "" ? " " : line}
</Text>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
width={Math.min(120, process.stdout.columns || 120)}
>
<Box paddingX={1}>
<Text bold>Working tree diff ({lines.length} lines)</Text>
</Box>
<Box flexDirection="column" paddingX={1}>
{visible.map((line, idx) => {
return renderLine(line, firstVisible + idx);
})}
</Box>
<Box paddingX={1}>
<Text dimColor>esc Close Scroll PgUp/PgDn g/G First/Last</Text>
</Box>
</Box>
);
}

View File

@@ -52,6 +52,12 @@ export default function HelpOverlay({
<Text>
<Text color="cyan">/clearhistory</Text> clear command history
</Text>
<Text>
<Text color="cyan">/bug</Text> file a bug report with session log
</Text>
<Text>
<Text color="cyan">/diff</Text> view working tree git diff
</Text>
<Text>
<Text color="cyan">/compact</Text> condense context into a summary
</Text>

View File

@@ -1,5 +1,5 @@
import Indicator, { type Props as IndicatorProps } from "./Indicator.js";
import ItemComponent, { type Props as ItemProps } from "./Item.js";
import Indicator, { type Props as IndicatorProps } from "./indicator.js";
import ItemComponent, { type Props as ItemProps } from "./item.js";
import isEqual from "fast-deep-equal";
import { Box, useInput } from "ink";
import React, {

View File

@@ -400,6 +400,7 @@ export function SinglePassApp({
});
const chatResp = await openai.beta.chat.completions.parse({
model: config.model,
...(config.flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "user",

View File

@@ -153,6 +153,78 @@ function TextInput({
useInput(
(input, key) => {
// ────────────────────────────────────────────────────────────────
// Support Shift+Enter / Ctrl+Enter from terminals that have
// modifyOtherKeys enabled. Such terminals encode the keycombo in a
// CSI sequence rather than sending a bare "\r"/"\n". Ink passes the
// sequence through as raw text (without the initial ESC), so we need to
// detect and translate it before the generic character handler below
// treats it as literal input (e.g. "[27;2;13~"). We support both the
// modern *mode 2* (CSIu, ending in "u") and the legacy *mode 1*
// variant (ending in "~").
//
// - Shift+Enter → insert newline (same behaviour as Option+Enter)
// - Ctrl+Enter → submit the input (same as plain Enter)
//
// References: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Modify-Other-Keys
// ────────────────────────────────────────────────────────────────
function handleEncodedEnterSequence(raw: string): boolean {
// CSIu (modifyOtherKeys=2) → "[13;<mod>u"
let m = raw.match(/^\[([0-9]+);([0-9]+)u$/);
if (m && m[1] === "13") {
const mod = Number(m[2]);
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(originalValue);
}
} else {
const newValue =
originalValue.slice(0, cursorOffset) +
"\n" +
originalValue.slice(cursorOffset);
setState({
cursorOffset: cursorOffset + 1,
cursorWidth: 0,
});
onChange(newValue);
}
return true; // handled
}
// CSI~ (modifyOtherKeys=1) → "[27;<mod>;13~"
m = raw.match(/^\[27;([0-9]+);13~$/);
if (m) {
const mod = Number(m[1]);
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(originalValue);
}
} else {
const newValue =
originalValue.slice(0, cursorOffset) +
"\n" +
originalValue.slice(cursorOffset);
setState({
cursorOffset: cursorOffset + 1,
cursorWidth: 0,
});
onChange(newValue);
}
return true; // handled
}
return false; // not an encoded Enter sequence
}
if (handleEncodedEnterSequence(input)) {
return;
}
if (
key.upArrow ||
key.downArrow ||

View File

@@ -141,9 +141,19 @@ export default class TextBuffer {
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-"));
// Prepare a temporary file with the current contents. We use mkdtempSync
// to obtain an isolated directory and avoid name collisions. Similar to
// other parts of the codebase we occasionally run inside restricted
// environments (e.g. GitHub Codespaces) where the OS-level tmp directory
// is not writable. In that case fall back to creating the directory under
// the current working directory so the workflow still functions.
let tmpDir: string;
try {
tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "codex-edit-"));
} catch {
tmpDir = fs.mkdtempSync(pathMod.join(process.cwd(), "codex-edit-"));
}
const filePath = pathMod.join(tmpDir, "buffer.txt");
fs.writeFileSync(filePath, this.getText(), "utf8");

View File

@@ -496,7 +496,6 @@ export class AgentLoop {
if (this.model.startsWith("o")) {
reasoning = { effort: "high" };
if (this.model === "o3" || this.model === "o4-mini") {
// @ts-expect-error waiting for API type update
reasoning.summary = "auto";
}
}
@@ -517,6 +516,7 @@ export class AgentLoop {
stream: true,
parallel_tool_calls: false,
reasoning,
...(this.config.flexMode ? { service_tier: "flex" } : {}),
tools: [
{
type: "function",
@@ -791,6 +791,41 @@ export class AgentLoop {
this.onLoading(false);
return;
}
// Suppress internal stack on JSON parse failures
if (err instanceof SyntaxError) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Failed to parse streaming response (invalid JSON). Please `/clear` to reset.",
},
],
});
this.onLoading(false);
return;
}
// Handle OpenAI API quota errors
if (
err instanceof Error &&
(err as { code?: string }).code === "insufficient_quota"
) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
},
],
});
this.onLoading(false);
return;
}
throw err;
} finally {
this.currentStream = null;

View File

@@ -1,5 +1,6 @@
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
import type { SpawnOptions } from "child_process";
import type { ParseEntry } from "shell-quote";
import { process_patch } from "./apply-patch.js";
import { SandboxType } from "./sandbox/interface.js";
@@ -8,9 +9,17 @@ import { exec as rawExec } from "./sandbox/raw-exec.js";
import { formatCommandForDisplay } from "../../format-command.js";
import fs from "fs";
import os from "os";
import { parse } from "shell-quote";
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
function requiresShell(cmd: Array<string>): boolean {
return cmd.some((arg) => {
const tokens = parse(arg) as Array<ParseEntry>;
return tokens.some((token) => typeof token === "object" && "op" in token);
});
}
/**
* 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.
@@ -33,6 +42,7 @@ export function exec(
const opts: SpawnOptions = {
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
...(requiresShell(cmd) ? { shell: true } : {}),
...(workdir ? { cwd: workdir } : {}),
};
// Merge default writable roots with any user-specified ones.

View File

@@ -3,7 +3,6 @@ import type { SpawnOptions } from "child_process";
import { exec } from "./raw-exec.js";
import { log } from "../log.js";
import { realpathSync } from "fs";
import { CONFIG_DIR } from "src/utils/config.js";
function getCommonRoots() {
@@ -30,9 +29,7 @@ export function execWithSeatbelt(
const { policies, params } = writableRoots
.map((root, index) => ({
policy: `(subpath (param "WRITABLE_ROOT_${index}"))`,
// the kernel resolves symlinks before handing them to seatbelt for checking
// so store the canonicalized form in the policy to be compared against
param: `-DWRITABLE_ROOT_${index}=${realpathSync(root)}`,
param: `-DWRITABLE_ROOT_${index}=${root}`,
}))
.reduce(
(

View File

@@ -0,0 +1,82 @@
import type {
ResponseItem,
ResponseOutputItem,
} from "openai/resources/responses/responses.mjs";
/**
* Build a GitHub issuesnew URL that prefills the Codex 2bugreport.yml
* template with whatever structured data we can infer from the current
* session.
*/
export function buildBugReportUrl({
items,
cliVersion,
model,
platform,
}: {
/** Chat history so we can summarise user steps */
items: Array<ResponseItem | ResponseOutputItem>;
/** CLI revision string (e.g. output of `codex --revision`) */
cliVersion: string;
/** Active model name */
model: string;
/** Platform string e.g. `darwin arm64 23.0.0` */
platform: string;
}): string {
const params = new URLSearchParams({
template: "2-bug-report.yml",
labels: "bug",
});
params.set("version", cliVersion);
params.set("model", model);
params.set("platform", platform);
const bullets: Array<string> = [];
for (let i = 0; i < items.length; ) {
const entry = items[i];
if (entry?.type === "message" && entry.role === "user") {
const contentArray = entry.content as
| Array<{ text?: string }>
| undefined;
const messageText = contentArray
?.map((c) => c.text ?? "")
.join(" ")
.trim();
let reasoning = 0;
let toolCalls = 0;
let j = i + 1;
while (j < items.length) {
const it = items[j];
if (it?.type === "message" && it?.role === "user") {
break;
} else if (
it?.type === "reasoning" ||
(it?.type === "message" && it?.role === "assistant")
) {
reasoning += 1;
} else if (it?.type === "function_call") {
toolCalls += 1;
}
j++;
}
const codeBlock = `\`\`\`\n ${messageText}\n \`\`\``;
bullets.push(
`- ${codeBlock}\n - \`${reasoning} reasoning\` | \`${toolCalls} tool\``,
);
i = j;
} else {
i += 1;
}
}
if (bullets.length) {
params.set("steps", bullets.join("\n"));
}
return `https://github.com/openai/codex/issues/new?${params.toString()}`;
}

View File

@@ -0,0 +1,143 @@
import { CONFIG_DIR } from "./config";
import boxen from "boxen";
import chalk from "chalk";
import * as cp from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import which from "which";
interface UpdateCheckState {
lastUpdateCheck?: string;
}
interface PackageInfo {
current: string;
wanted: string;
latest: string;
dependent: string;
location: string;
}
interface UpdateCheckInfo {
currentVersion: string;
latestVersion: string;
}
const UPDATE_CHECK_FREQUENCY = 1000 * 60 * 60 * 24; // 1 day
export async function getNPMCommandPath(): Promise<string | undefined> {
try {
return await which(process.platform === "win32" ? "npm.cmd" : "npm");
} catch {
return undefined;
}
}
export async function checkOutdated(
npmCommandPath: string,
): Promise<UpdateCheckInfo | undefined> {
return new Promise((resolve, _reject) => {
// TODO: support local installation
// Right now we're using "--global", which only checks global packages.
// But codex might be installed locally — we should check the local version first,
// and only fall back to the global one if needed.
const args = ["outdated", "--global", "--json", "--", "@openai/codex"];
// corepack npm wrapper would automatically update package.json. disable that behavior.
// COREPACK_ENABLE_AUTO_PIN disables the package.json overwrite, and
// COREPACK_ENABLE_PROJECT_SPEC makes the npm view command succeed
// even if packageManager specified a package manager other than npm.
const env = {
...process.env,
COREPACK_ENABLE_AUTO_PIN: "0",
COREPACK_ENABLE_PROJECT_SPEC: "0",
};
let options: cp.ExecFileOptions = { env };
let commandPath = npmCommandPath;
if (process.platform === "win32") {
options = { ...options, shell: true };
commandPath = `"${npmCommandPath}"`;
}
cp.execFile(commandPath, args, options, async (_error, stdout) => {
try {
const { name: packageName } = await import("../../package.json");
const content: Record<string, PackageInfo> = JSON.parse(stdout);
if (!content[packageName]) {
// package not installed or not outdated
resolve(undefined);
return;
}
const currentVersion = content[packageName].current;
const latestVersion = content[packageName].latest;
resolve({ currentVersion, latestVersion });
return;
} catch {
// ignore
}
resolve(undefined);
});
});
}
export async function checkForUpdates(): Promise<void> {
const stateFile = join(CONFIG_DIR, "update-check.json");
let state: UpdateCheckState | undefined;
try {
state = JSON.parse(await readFile(stateFile, "utf8"));
} catch {
// ignore
}
if (
state?.lastUpdateCheck &&
Date.now() - new Date(state.lastUpdateCheck).valueOf() <
UPDATE_CHECK_FREQUENCY
) {
return;
}
const npmCommandPath = await getNPMCommandPath();
if (!npmCommandPath) {
return;
}
const packageInfo = await checkOutdated(npmCommandPath);
await writeState(stateFile, {
...state,
lastUpdateCheck: new Date().toUTCString(),
});
if (!packageInfo) {
return;
}
const updateMessage = `To update, run: ${chalk.cyan(
"npm install -g @openai/codex",
)} to update.`;
const box = boxen(
`\
Update available! ${chalk.red(packageInfo.currentVersion)}${chalk.green(
packageInfo.latestVersion,
)}.
${updateMessage}`,
{
padding: 1,
margin: 1,
align: "center",
borderColor: "yellow",
borderStyle: "round",
},
);
// eslint-disable-next-line no-console
console.log(box);
}
async function writeState(stateFilePath: string, state: UpdateCheckState) {
await writeFile(stateFilePath, JSON.stringify(state, null, 2), {
encoding: "utf8",
});
}

View File

@@ -9,9 +9,17 @@ import OpenAI from "openai";
* @param model The model to use for generating the summary
* @returns A concise structured summary string
*/
/**
* Generate a condensed summary of the conversation items.
* @param items The list of conversation items to summarize
* @param model The model to use for generating the summary
* @param flexMode Whether to use the flex-mode service tier
* @returns A concise structured summary string
*/
export async function generateCompactSummary(
items: Array<ResponseItem>,
model: string,
flexMode = false,
): Promise<string> {
const oai = new OpenAI({
apiKey: process.env["OPENAI_API_KEY"],
@@ -44,6 +52,7 @@ export async function generateCompactSummary(
const response = await oai.chat.completions.create({
model,
...(flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "assistant",

View File

@@ -56,6 +56,8 @@ export type StoredConfig = {
saveHistory?: boolean;
sensitivePatterns?: Array<string>;
};
/** User-defined safe commands */
safeCommands?: Array<string>;
};
// Minimal config written on first run. An *empty* model string ensures that
@@ -75,15 +77,21 @@ export type AppConfig = {
apiKey?: string;
model: string;
instructions: string;
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
/** Whether to enable desktop notifications for responses */
notify: boolean;
/** Enable the "flex-mode" processing mode for supported models (o3, o4-mini) */
flexMode?: boolean;
history?: {
maxSize: number;
saveHistory: boolean;
sensitivePatterns: Array<string>;
};
/** User-defined safe commands */
safeCommands?: Array<string>;
};
// ---------------------------------------------------------------------------
@@ -268,6 +276,8 @@ export const loadConfig = (
: DEFAULT_AGENTIC_MODEL),
instructions: combinedInstructions,
notify: storedConfig.notify === true,
approvalMode: storedConfig.approvalMode,
safeCommands: storedConfig.safeCommands ?? [],
};
// -----------------------------------------------------------------------
@@ -345,6 +355,13 @@ export const loadConfig = (
};
}
// Load user-defined safe commands
if (Array.isArray(storedConfig.safeCommands)) {
config.safeCommands = storedConfig.safeCommands.map(String);
} else {
config.safeCommands = [];
}
return config;
};
@@ -376,6 +393,7 @@ export const saveConfig = (
// Create the config object to save
const configToSave: StoredConfig = {
model: config.model,
approvalMode: config.approvalMode,
};
// Add history settings if they exist
@@ -386,6 +404,10 @@ export const saveConfig = (
sensitivePatterns: config.history.sensitivePatterns,
};
}
// Save: User-defined safe commands
if (config.safeCommands && config.safeCommands.length > 0) {
configToSave.safeCommands = config.safeCommands;
}
if (ext === ".yaml" || ext === ".yml") {
writeFileSync(targetPath, dumpYaml(configToSave), "utf-8");

View File

@@ -0,0 +1,36 @@
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
/**
* Extracts the patch texts of all `apply_patch` tool calls from the given
* message history. Returns an empty string when none are found.
*/
export function extractAppliedPatches(items: Array<ResponseItem>): string {
const patches: Array<string> = [];
for (const item of items) {
if (item.type !== "function_call") {
continue;
}
const { name: toolName, arguments: argsString } = item as unknown as {
name: unknown;
arguments: unknown;
};
if (toolName !== "apply_patch" || typeof argsString !== "string") {
continue;
}
try {
const args = JSON.parse(argsString) as { patch?: string };
if (typeof args.patch === "string" && args.patch.length > 0) {
patches.push(args.patch.trim());
}
} catch {
// Ignore malformed JSON we never want to crash the overlay.
continue;
}
}
return patches.join("\n\n");
}

View File

@@ -0,0 +1,29 @@
import { execSync } from "node:child_process";
/**
* Returns the current Git diff for the working directory. If the current
* working directory is not inside a Git repository, `isGitRepo` will be
* false and `diff` will be an empty string.
*/
export function getGitDiff(): {
isGitRepo: boolean;
diff: string;
} {
try {
// First check whether we are inside a git repository. `revparse` exits
// with a nonzero status code if not.
execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" });
// If the above call didnt throw, we are inside a git repo. Retrieve the
// diff including color codes so that the overlay can render them.
const output = execSync("git diff --color", {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now
});
return { isGitRepo: true, diff: output };
} catch {
// Either git is not installed or were not inside a repository.
return { isGitRepo: false, diff: "" };
}
}

View File

@@ -0,0 +1,79 @@
import { fileURLToPath } from "node:url";
// ---------------------------------------------------------------------------
// Helper to find image file paths inside free-form text that users may paste
// or drag-drop into the terminal. Returns the cleaned-up text (with the image
// references removed) *and* the list of absolute or relative paths that were
// found.
// ---------------------------------------------------------------------------
const IMAGE_EXT_REGEX = "(?:png|jpe?g|gif|bmp|webp|svg)"; // deliberately kept simple
// Pattern helpers compiled lazily so the whole file can be tree-shaken if
// unused by a particular build target.
let MARKDOWN_LINK_RE: RegExp;
let QUOTED_PATH_RE: RegExp;
let BARE_PATH_RE: RegExp;
function compileRegexes() {
if (MARKDOWN_LINK_RE) {
return;
}
// Capture path inside markdown image link e.g. ![alt](path)
MARKDOWN_LINK_RE = /!\[[^\]]*?\]\(([^)]+)\)/g;
// Any quoted image path single or double quotes
QUOTED_PATH_RE = new RegExp(`["']([^"']+?[.]${IMAGE_EXT_REGEX})["']`, "gi");
// Bare image paths appearing in text. Handles absolute, relative, and
// Windows drive-letter paths.
BARE_PATH_RE = new RegExp(
`(?:\\.[/\\\\]|[/\\\\]|[A-Za-z]:[/\\\\])?[\\w-]+(?:[/\\\\][\\w-]+)*.${IMAGE_EXT_REGEX}`,
"gi",
);
}
export interface ExtractResult {
paths: Array<string>;
text: string;
}
export function extractImagePaths(input: string): ExtractResult {
compileRegexes();
const paths: Array<string> = [];
let text = input;
const replace = (
re: RegExp,
mapper: (match: string, path: string) => string,
) => {
text = text.replace(re, mapper);
};
// 1) Markdown ![alt](path)
replace(MARKDOWN_LINK_RE, (_m, p1: string) => {
paths.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
return "";
});
// 2) Quoted
replace(QUOTED_PATH_RE, (_m, p1: string) => {
paths.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
return "";
});
// 3) Bare
replace(BARE_PATH_RE, (match: string) => {
paths.push(match.startsWith("file://") ? fileURLToPath(match) : match);
return "";
});
// Remove any leftover leading slash that was immediately followed by the
// matched path (e.g. "/Users/foo.png → '/ '" after replacement). We only
// strip it when it's followed by whitespace or end-of-string so normal
// typing like "/help" is untouched.
text = text.replace(/(^|\s)\/(?=\s|$)/g, "$1");
return { paths, text };
}

View File

@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
/** Determine if a filename looks like an image. */
export function isImage(filename: string): boolean {
return /\.(png|jpe?g|gif|bmp|webp|svg)$/i.test(filename);
}
export interface PickerItem {
label: string;
value: string;
// When value is "__UP__" this represents the synthetic "../" entry.
}
/**
* Return selectable items for the given directory. Directories appear *after*
* images (so that pressing <enter> immediately selects the first image).
* The synthetic "../" entry is always first unless we are already at
* pickerRoot in which case it is omitted.
*/
export function getDirectoryItems(
cwd: string,
pickerRoot: string,
): Array<PickerItem> {
const files: Array<PickerItem> = [];
const dirs: Array<PickerItem> = [];
try {
for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
if (entry.isDirectory()) {
dirs.push({
label: entry.name + "/",
value: path.join(cwd, entry.name),
});
} else if (entry.isFile() && isImage(entry.name)) {
files.push({ label: entry.name, value: path.join(cwd, entry.name) });
}
}
} catch {
// ignore errors return empty list so UI shows "No images".
}
files.sort((a, b) => a.label.localeCompare(b.label));
dirs.sort((a, b) => a.label.localeCompare(b.label));
const items: Array<PickerItem> = [];
if (path.resolve(cwd) !== path.resolve(pickerRoot)) {
items.push({ label: "../", value: "__UP__" });
}
items.push(...files, ...dirs);
return items;
}

View File

@@ -2,6 +2,11 @@ import type { ResponseInputItem } from "openai/resources/responses/responses";
import { fileTypeFromBuffer } from "file-type";
import fs from "fs/promises";
import path from "node:path";
// Map dataurls → original filenames so the TUI can render friendly labels.
// This map is populated during `createInputItem` execution.
export const imageFilenameByDataUrl = new Map<string, string>();
export async function createInputItem(
text: string,
@@ -15,15 +20,50 @@ export async function createInputItem(
for (const filePath of images) {
/* eslint-disable no-await-in-loop */
const binary = await fs.readFile(filePath);
let binary: Buffer | undefined;
try {
binary = await fs.readFile(filePath);
} catch (err: unknown) {
// Gracefully handle files that no longer exist on disk. This can happen
// when an image was attached earlier but has since been moved or deleted
// before the user submitted the prompt.
const e = err as NodeJS.ErrnoException;
if (e?.code === "ENOENT") {
// Insert a placeholder message so the user is aware a file was missing.
inputItem.content.push({
type: "input_text",
text: `[missing image: ${path.basename(filePath)}]`,
});
continue; // skip to next image
}
// For any other error (e.g. permission issues) bubble up so callers can
// react accordingly.
throw err as Error;
}
if (!binary) {
// Should not happen, but satisfies TypeScript.
continue;
}
const kind = await fileTypeFromBuffer(binary);
/* eslint-enable no-await-in-loop */
const encoded = binary.toString("base64");
const mime = kind?.mime ?? "application/octet-stream";
const dataUrl = `data:${mime};base64,${encoded}`;
// Store a pretty label (make path relative when possible) so the TUI can
// display something friendlier than a long dataurl.
const label = path.isAbsolute(filePath)
? path.relative(process.cwd(), filePath)
: filePath;
imageFilenameByDataUrl.set(dataUrl, label);
inputItem.content.push({
type: "input_image",
detail: "auto",
image_url: `data:${mime};base64,${encoded}`,
image_url: dataUrl,
});
}

View File

@@ -1,4 +1,4 @@
export const CLI_VERSION = "0.1.2504172351"; // Must be in sync with package.json.
export const CLI_VERSION = "0.1.2504181820"; // Must be in sync with package.json.
export const ORIGIN = "codex_cli_ts";
export type TerminalChatSession = {

View File

@@ -0,0 +1,32 @@
// Defines the available slash commands and their descriptions.
// Used for autocompletion in the chat input.
export interface SlashCommand {
command: string;
description: string;
}
export const SLASH_COMMANDS: Array<SlashCommand> = [
{
command: "/clear",
description: "Clear conversation history and free up context",
},
{
command: "/clearhistory",
description: "Clear command history",
},
{
command: "/compact",
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
},
{ command: "/history", description: "Open command history" },
{ command: "/help", description: "Show list of commands" },
{ command: "/model", description: "Open model selection panel" },
{ command: "/approval", description: "Open approval mode selection panel" },
{ command: "/bug", description: "Generate a prefilled GitHub bug report" },
{
command: "/diff",
description:
"Show git diff of the working directory (or applied patches if not in git)",
},
];

View File

@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Check for updates > should outputs the update message when package is outdated 1`] = `
"
╭─────────────────────────────────────────────────────────────╮
│ │
│ Update available! 1.0.0 → 2.0.0. │
│ To update, run: npm install -g @openai/codex to update. │
│ │
╰─────────────────────────────────────────────────────────────╯
"
`;

View File

@@ -87,9 +87,16 @@ import { loadConfig } from "../src/utils/config.js";
let projectDir: string;
// beforeEach runs once per test; when the sandbox blocks mkdtemp under the OS
// tmp directory (e.g. GitHub Codespaces or certain container runtimes) falls
// back to creating the directory under the current working directory so the
// suite can still run.
beforeEach(() => {
// Create a fresh temporary directory to act as an isolated git repo.
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
try {
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
} catch {
projectDir = mkdtempSync(join(process.cwd(), "codex-proj-"));
}
mkdirSync(join(projectDir, ".git")); // mark as project root
// Write a small project doc that we expect to be included in the prompt.

View File

@@ -1,7 +1,13 @@
import type { SafetyAssessment } from "../src/approvals";
import { canAutoApprove } from "../src/approvals";
import { describe, test, expect } from "vitest";
import { describe, test, expect, vi } from "vitest";
vi.mock("../src/utils/config", () => ({
loadConfig: () => ({
safeCommands: ["npm test", "sl"],
}),
}));
describe("canAutoApprove()", () => {
const env = {
@@ -89,4 +95,27 @@ describe("canAutoApprove()", () => {
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
});
test("commands in safeCommands config should be safe", async () => {
expect(check(["npm", "test"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["sl"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["npm", "test", "--watch"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
});
});

View File

@@ -0,0 +1,92 @@
// Attachment preview shows selected images and clears with Ctrl+U
import fs from "node:fs";
import path from "node:path";
import React from "react";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { renderTui } from "./ui-test-helpers.js";
vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async () => ({})),
imageFilenameByDataUrl: new Map(),
}));
// mock external deps used inside chat input
// Mock approval helper used by TerminalChatInput
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
// Accept an array of command tokens and join them with spaces for display.
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
function props() {
return {
isNew: true,
loading: false,
submitInput: () => {},
confirmationPrompt: null,
submitConfirmation: () => {},
setLastResponseId: () => {},
setItems: () => {},
contextLeftPercent: 100,
openOverlay: () => {},
openModelOverlay: () => {},
openApprovalOverlay: () => {},
openHelpOverlay: () => {},
interruptAgent: () => {},
active: true,
onCompact: () => {},
openDiffOverlay: () => {},
thinkingSeconds: 0,
};
}
describe("Chat input attachment preview", () => {
const TMP = path.join(process.cwd(), "attachment-preview-test");
const IMG = path.join(TMP, "foo.png");
beforeAll(() => {
fs.mkdirSync(TMP, { recursive: true });
fs.writeFileSync(IMG, "");
});
afterAll(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
it("shows image then clears with Ctrl+U", async () => {
const orig = process.cwd();
process.chdir(TMP);
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
React.createElement(TerminalChatInput, props()),
);
await flush();
await type(stdin, "@", flush);
await type(stdin, "\r", flush); // choose first
const frame1 = lastFrameStripped();
expect(frame1.match(/foo\.png/g)?.length ?? 0).toBe(1);
await type(stdin, "\x07", flush); // Ctrl+G (clear images only)
expect(lastFrameStripped()).not.toContain("foo.png");
cleanup();
process.chdir(orig);
});
});

View File

@@ -0,0 +1,87 @@
// Backspace removes last attached image when draft is empty
import fs from "node:fs";
import path from "node:path";
import React from "react";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { renderTui } from "./ui-test-helpers.js";
vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async () => ({})),
imageFilenameByDataUrl: new Map(),
}));
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
function props() {
return {
isNew: true,
loading: false,
submitInput: () => {},
confirmationPrompt: null,
submitConfirmation: () => {},
setLastResponseId: () => {},
setItems: () => {},
contextLeftPercent: 100,
openOverlay: () => {},
openModelOverlay: () => {},
openApprovalOverlay: () => {},
openHelpOverlay: () => {},
interruptAgent: () => {},
active: true,
onCompact: () => {},
openDiffOverlay: () => {},
thinkingSeconds: 0,
};
}
describe("Backspace deletes attached image", () => {
const TMP = path.join(process.cwd(), "backspace-delete-image-test");
const IMG = path.join(TMP, "bar.png");
beforeAll(() => {
fs.mkdirSync(TMP, { recursive: true });
fs.writeFileSync(IMG, "");
});
afterAll(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
it("removes image on backspace", async () => {
const orig = process.cwd();
process.chdir(TMP);
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
React.createElement(TerminalChatInput, props()),
);
await flush();
await type(stdin, "@", flush);
await type(stdin, "\r", flush);
const frame1 = lastFrameStripped();
expect(frame1.match(/bar\.png/g)?.length ?? 0).toBe(1);
await type(stdin, "\x7f", flush);
expect(lastFrameStripped()).not.toContain("bar.png");
cleanup();
process.chdir(orig);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
checkForUpdates,
checkOutdated,
getNPMCommandPath,
} from "../src/utils/check-updates.js";
import { execFile } from "node:child_process";
import { join } from "node:path";
import { CONFIG_DIR } from "../src/utils/config.js";
vi.mock("which", () => ({
default: vi.fn(() => "/usr/local/bin/npm"),
}));
vi.mock("child_process", () => ({
execFile: vi.fn((_cmd, _args, _opts, callback) => {
const stdout = JSON.stringify({
"@openai/codex": {
current: "1.0.0",
latest: "2.0.0",
},
});
callback?.(null, stdout, "");
return {} as any;
}),
}));
let memfs: Record<string, string> = {};
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal()),
readFile: async (path: string) => {
if (memfs[path] === undefined) {
throw new Error("ENOENT");
}
return memfs[path];
},
writeFile: async (path: string, data: string) => {
memfs[path] = data;
},
}));
beforeEach(() => {
memfs = {}; // reset inmemory store
});
describe("Check for updates", () => {
it("should return the path to npm", async () => {
const npmPath = await getNPMCommandPath();
expect(npmPath).toBeDefined();
});
it("should return undefined if npm is not found", async () => {
vi.mocked(await import("which")).default.mockImplementationOnce(() => {
throw new Error("not found");
});
const npmPath = await getNPMCommandPath();
expect(npmPath).toBeUndefined();
});
it("should return the return value when package is outdated", async () => {
const npmPath = await getNPMCommandPath();
const info = await checkOutdated(npmPath!);
expect(info).toStrictEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
});
});
it("should return undefined when package is not outdated", async () => {
const npmPath = await getNPMCommandPath();
vi.mocked(execFile).mockImplementationOnce(
(_cmd, _args, _opts, callback) => {
// Simulate the case where the package is not outdated, returning an empty object
const stdout = JSON.stringify({});
callback?.(null, stdout, "");
return {} as any;
},
);
const info = await checkOutdated(npmPath!);
expect(info).toBeUndefined();
});
it("should outputs the update message when package is outdated", async () => {
const codexStatePath = join(CONFIG_DIR, "update-check.json");
// Use a fixed early date far in the past to ensure it's always at least 1 day before now
memfs[codexStatePath] = JSON.stringify({
lastUpdateCheck: new Date("2000-01-01T00:00:00Z").toUTCString(),
});
// Spy on console.log to capture output BEFORE calling the checker so we
// capture the very first message that is printed when an update is
// detected.
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await checkForUpdates();
expect(logSpy).toHaveBeenCalled();
// The last call should be the boxen message
const lastCallArg = logSpy.mock.calls.at(-1)?.[0] ?? "";
// Strip ANSI colors to make snapshot stable across environments
const stripAnsi = (await import("strip-ansi")).default as (
input: string,
) => string;
expect(stripAnsi(lastCallArg)).toMatchSnapshot();
});
it("should not output the update message when package is not outdated", async () => {
const codexStatePath = join(CONFIG_DIR, "update-check.json");
memfs[codexStatePath] = JSON.stringify({
lastUpdateCheck: new Date().toUTCString(),
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await checkForUpdates();
expect(logSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import type * as fsType from "fs";
import { loadConfig, saveConfig } from "../src/utils/config.js"; // parent import first
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
import { tmpdir } from "os";
import { join } from "path";
import { test, expect, beforeEach, afterEach, vi } from "vitest";
@@ -107,3 +108,43 @@ test("loads user instructions + project doc when codex.md is present", () => {
userInstr + "\n\n--- project-doc ---\n\n" + projectDoc,
);
});
test("loads and saves approvalMode correctly", () => {
// Setup config with approvalMode
memfs[testConfigPath] = JSON.stringify(
{
model: "mymodel",
approvalMode: AutoApprovalMode.AUTO_EDIT,
},
null,
2,
);
memfs[testInstructionsPath] = "test instructions";
// Load config and verify approvalMode
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
// Check approvalMode was loaded correctly
expect(loadedConfig.approvalMode).toBe(AutoApprovalMode.AUTO_EDIT);
// Modify approvalMode and save
const updatedConfig = {
...loadedConfig,
approvalMode: AutoApprovalMode.FULL_AUTO,
};
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
// Verify saved config contains updated approvalMode
expect(memfs[testConfigPath]).toContain(
`"approvalMode": "${AutoApprovalMode.FULL_AUTO}"`,
);
// Load again and verify updated value
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
expect(reloadedConfig.approvalMode).toBe(AutoApprovalMode.FULL_AUTO);
});

View File

@@ -0,0 +1,156 @@
// Dropping / pasting an image path into the chat input should immediately move
// that image into the attached-images preview and remove the path from the draft
// text.
import fs from "node:fs";
import path from "node:path";
import React from "react";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { renderTui } from "./ui-test-helpers.js";
// ---------------------------------------------------------------------------
// Mocks keep in sync with other TerminalChatInput UI tests
// ---------------------------------------------------------------------------
// We need to capture a reference to the mocked `createInputItem` function so we
// can make assertions later in the test, _and_ respect Vitests requirement
// that any variables used inside the `vi.mock` factory are already defined at
// the time the factory is hoisted. To satisfy both constraints we:
// 1. Declare the variable with `let` (so its hoisted), **without** assigning
// a value yet.
// 2. Inside the factory, create the mock with `vi.fn()` and assign it to the
// outer-scoped variable before returning it.
// This avoids the “there was an error when mocking a module” failure that
// occurs when a factory closes over an uninitialised top-level `const`.
// Using `var` ensures the binding is hoisted, so it exists (as `undefined`) at
// the time the `vi.mock` factory runs. We re-assign it inside the factory.
// eslint-disable-next-line no-var
var createInputItemMock!: ReturnType<typeof vi.fn>;
vi.mock("../src/utils/input-utils.js", () => {
// Initialise the mock lazily inside the factory so the reference is valid
// when the module is evaluated.
createInputItemMock = vi.fn(async () => ({}));
return {
createInputItem: createInputItemMock,
imageFilenameByDataUrl: new Map(),
};
});
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
function props() {
return {
isNew: true,
loading: false,
submitInput: () => {},
confirmationPrompt: null,
submitConfirmation: () => {},
setLastResponseId: () => {},
setItems: () => {},
contextLeftPercent: 100,
openOverlay: () => {},
openModelOverlay: () => {},
openApprovalOverlay: () => {},
openHelpOverlay: () => {},
interruptAgent: () => {},
active: true,
onCompact: () => {},
openDiffOverlay: () => {},
thinkingSeconds: 0,
};
}
describe("Drag-and-drop image attachment", () => {
const TMP = path.join(process.cwd(), "drag-drop-image-test");
const IMG = path.join(TMP, "dropped.png");
beforeAll(() => {
fs.mkdirSync(TMP, { recursive: true });
fs.writeFileSync(IMG, "");
});
afterAll(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
it("moves pasted path to attachment preview", async () => {
process.env["DEBUG_TCI"] = "1";
const orig = process.cwd();
process.chdir(TMP);
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
React.createElement(TerminalChatInput, props()),
);
await flush(); // initial render
// Simulate user pasting the bare filename (as most terminals do when you
// drag a file).
await type(stdin, "dropped.png ", flush);
await flush();
// A second flush to allow state updates triggered asynchronously by
// setState inside the onChange handler.
await flush();
const frame = lastFrameStripped();
expect(frame.match(/dropped\.png/g)?.length ?? 0).toBe(1);
// Now submit the message.
await type(stdin, "\r", flush);
await flush();
// createInputItem should have been called with the dropped image path
expect(createInputItemMock).toHaveBeenCalled();
const calls: Array<Array<unknown>> = createInputItemMock.mock.calls as any;
const lastCall = calls[calls.length - 1] as Array<unknown>;
expect(lastCall?.[1 as number]).toEqual(["dropped.png"]);
cleanup();
process.chdir(orig);
});
it("does NOT show slash-command overlay for absolute paths", async () => {
const orig = process.cwd();
process.chdir(TMP);
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
React.createElement(TerminalChatInput, props()),
);
await flush();
// absolute path starting with '/'
const absPath = path.join(TMP, "dropped.png");
await type(stdin, `${absPath} `, flush);
await flush();
const frame = lastFrameStripped();
// Should contain attachment preview but NOT typical slash-command suggestion like "/help"
expect(frame).toContain("dropped.png");
expect(frame).not.toContain("/help");
cleanup();
process.chdir(orig);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { extractImagePaths } from "../src/utils/image-detector.js";
describe("extractImagePaths", () => {
it("detects markdown image", () => {
const { paths, text } = extractImagePaths(
"hello ![alt](foo/bar.png) world",
);
expect(paths).toEqual(["foo/bar.png"]);
expect(text).toBe("hello world");
});
it("detects quoted image", () => {
const { paths, text } = extractImagePaths('drag "baz.jpg" here');
expect(paths).toEqual(["baz.jpg"]);
expect(text).toBe("drag here");
});
it("detects bare path", () => {
const { paths, text } = extractImagePaths("see /tmp/img.gif please");
expect(paths).toEqual(["/tmp/img.gif"]);
expect(text).toBe("see please");
});
});

View File

@@ -0,0 +1,74 @@
import fs from "node:fs";
import path from "node:path";
import React from "react";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { renderTui } from "./ui-test-helpers.js";
vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async () => ({})),
imageFilenameByDataUrl: new Map(),
}));
import ImagePickerOverlay from "../src/components/chat/image-picker-overlay";
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
describe("Image picker overlay", () => {
let TMP: string;
let CHILD: string;
beforeAll(() => {
TMP = fs.mkdtempSync(path.join(process.cwd(), "overlay-test-"));
CHILD = path.join(TMP, "child");
fs.mkdirSync(CHILD, { recursive: true });
fs.writeFileSync(path.join(TMP, "a.png"), "");
fs.writeFileSync(path.join(TMP, "b.png"), "");
fs.writeFileSync(path.join(CHILD, "nested.png"), "");
});
afterAll(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
it("shows ../ when below root and selects it", async () => {
const onChangeDir = vi.fn();
const { lastFrameStripped, stdin, flush } = renderTui(
React.createElement(ImagePickerOverlay, {
rootDir: TMP,
cwd: CHILD,
onPick: () => {},
onCancel: () => {},
onChangeDir,
}),
);
await flush();
expect(lastFrameStripped()).toContain(" ../");
await type(stdin, "\r", flush);
expect(onChangeDir).toHaveBeenCalledWith(path.dirname(CHILD));
});
it("selecting file calls onPick", async () => {
const onPick = vi.fn();
const { stdin, flush } = renderTui(
React.createElement(ImagePickerOverlay, {
rootDir: TMP,
cwd: TMP,
onPick,
onCancel: () => {},
onChangeDir: () => {},
}),
);
await flush();
await type(stdin, "\r", flush);
expect(onPick).toHaveBeenCalledWith(path.join(TMP, "a.png"));
});
});

View File

@@ -0,0 +1,79 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { renderTui } from "./ui-test-helpers.js";
import TerminalInlineImage from "../src/components/chat/terminal-inline-image.js";
import TerminalChatResponseItem from "../src/components/chat/terminal-chat-response-item.js";
import {
imageFilenameByDataUrl,
createInputItem,
} from "../src/utils/input-utils.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import path from "node:path";
import fs from "node:fs";
describe("TerminalInlineImage fallback", () => {
it("renders alt text in test env", () => {
const { lastFrameStripped } = renderTui(
<TerminalInlineImage src={Buffer.from("abc")} alt="placeholder" />,
);
expect(lastFrameStripped()).toContain("placeholder");
});
});
function fakeImageMessage(filename: string) {
const url = "data:image/png;base64,AAA";
imageFilenameByDataUrl.set(url, filename);
return {
id: "test-id",
type: "message",
role: "user",
content: [
{ type: "input_text", text: "hello" },
{ type: "input_image", detail: "auto", image_url: url },
],
};
}
describe("TerminalChatResponseItem image label", () => {
it("shows filename", () => {
const msg = fakeImageMessage("sample.png");
const { lastFrameStripped } = renderTui(
<TerminalChatResponseItem item={msg as any} />,
);
expect(lastFrameStripped()).toContain('<Image path="sample.png">');
});
});
// ---------------------------------------------------------------------------
// New tests ensure createInputItem gracefully skips missing images.
// ---------------------------------------------------------------------------
describe("createInputItem missing images", () => {
it("ignores images that never existed on disk (conversation start)", async () => {
const item = await createInputItem("hello", ["ghost.png"]);
expect(item.content.some((c) => c.type === "input_image")).toBe(false);
});
it("ignores images deleted before submit (midconversation)", async () => {
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "missing-img-"));
const imgPath = path.join(tmpDir, "temp.png");
fs.writeFileSync(imgPath, "dummy");
// Remove the file before we construct the message.
fs.rmSync(imgPath);
const item = await createInputItem("", [imgPath]);
expect(item.content.some((c) => c.type === "input_image")).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Additional integration tests for the systemlevel warning are covered in
// higherlevel suites. This unit file focuses on createInputItem behaviour.
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import fs from "fs/promises";
import { createInputItem } from "../src/utils/input-utils.js";
describe("createInputItem", () => {
it("returns only text when no images provided", async () => {
const result = await createInputItem("hello", []);
expect(result).toEqual({
role: "user",
type: "message",
content: [{ type: "input_text", text: "hello" }],
});
});
it("includes image content for existing file", async () => {
const fakeBuffer = Buffer.from("fake image content");
const readSpy = vi
.spyOn(fs, "readFile")
.mockResolvedValue(fakeBuffer as any);
const result = await createInputItem("hello", ["dummy-path"]);
const expectedUrl = `data:application/octet-stream;base64,${fakeBuffer.toString(
"base64",
)}`;
expect(result.role).toBe("user");
expect(result.type).toBe("message");
expect(result.content.length).toBe(2);
const [textItem, imageItem] = result.content;
expect(textItem).toEqual({ type: "input_text", text: "hello" });
expect(imageItem).toEqual({
type: "input_image",
detail: "auto",
image_url: expectedUrl,
});
readSpy.mockRestore();
});
it("falls back to missing image text for non-existent file", async () => {
const filePath = "tests/__fixtures__/does-not-exist.png";
const result = await createInputItem("hello", [filePath]);
expect(result.content.length).toBe(2);
const fallbackItem = result.content[1];
expect(fallbackItem).toEqual({
type: "input_text",
text: "[missing image: does-not-exist.png]",
});
});
});

View File

@@ -0,0 +1,49 @@
// Regression test: Terminals with modifyOtherKeys=1 emit CSI~ sequence for
// Shift+Enter: ESC [ 27 ; mod ; 13 ~. The editor must treat Shift+Enter as
// newline (without submitting) and Ctrl+Enter as submit.
import { renderTui } from "./ui-test-helpers.js";
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
async function type(
stdin: NodeJS.WritableStream,
text: string,
flush: () => Promise<void>,
) {
stdin.write(text);
await flush();
}
describe("MultilineTextEditor Shift+Enter with modifyOtherKeys=1", () => {
it("inserts newline, does NOT submit", async () => {
const onSubmit = vi.fn();
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
React.createElement(MultilineTextEditor, {
height: 5,
width: 20,
initialText: "",
onSubmit,
}),
);
await flush();
await type(stdin, "abc", flush);
// Shift+Enter => ESC [27;2;13~
await type(stdin, "\u001B[27;2;13~", flush);
await type(stdin, "def", flush);
const frame = lastFrameStripped();
expect(frame).toMatch(/abc/);
expect(frame).toMatch(/def/);
// newline inserted -> at least 2 lines
expect(frame.split("\n").length).toBeGreaterThanOrEqual(2);
expect(onSubmit).not.toHaveBeenCalled();
cleanup();
});
});

View File

@@ -8,8 +8,13 @@ let projectDir: string;
let configPath: string;
let instructionsPath: string;
// Use OS tmpdir unless blocked; fallback to cwd.
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
try {
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
} catch {
projectDir = mkdtempSync(join(process.cwd(), "codex-proj-"));
}
// Create fake .git dir to mark project root
mkdirSync(join(projectDir, ".git"));

View File

@@ -0,0 +1,39 @@
import { test, expect } from "vitest";
import { SLASH_COMMANDS, type SlashCommand } from "../src/utils/slash-commands";
test("SLASH_COMMANDS includes expected commands", () => {
const commands = SLASH_COMMANDS.map((c: SlashCommand) => c.command);
expect(commands).toContain("/clear");
expect(commands).toContain("/compact");
expect(commands).toContain("/history");
expect(commands).toContain("/help");
expect(commands).toContain("/model");
expect(commands).toContain("/approval");
expect(commands).toContain("/clearhistory");
expect(commands).toContain("/diff");
});
test("filters slash commands by prefix", () => {
const prefix = "/c";
const filtered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(prefix),
);
const names = filtered.map((c: SlashCommand) => c.command);
expect(names).toEqual(
expect.arrayContaining(["/clear", "/clearhistory", "/compact"]),
);
expect(names).not.toEqual(
expect.arrayContaining(["/history", "/help", "/model", "/approval"]),
);
const emptyPrefixFiltered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(""),
);
const emptyPrefixNames = emptyPrefixFiltered.map(
(c: SlashCommand) => c.command,
);
expect(emptyPrefixNames).toEqual(
expect.arrayContaining(SLASH_COMMANDS.map((c: SlashCommand) => c.command)),
);
expect(emptyPrefixNames).toHaveLength(SLASH_COMMANDS.length);
});

View File

@@ -17,12 +17,14 @@ describe("TerminalChatInput compact command", () => {
setItems: () => {},
contextLeftPercent: 10,
openOverlay: () => {},
openDiffOverlay: () => {},
openModelOverlay: () => {},
openApprovalOverlay: () => {},
openHelpOverlay: () => {},
onCompact: () => {},
interruptAgent: () => {},
active: true,
thinkingSeconds: 0,
};
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
const frame = lastFrameStripped();

View File

@@ -0,0 +1,36 @@
// Vitest setup file executed in every test worker before any individual test
// suites are imported. Node.js disallows `process.chdir()` inside worker
// threads starting from v22 which causes tests that attempt to change the
// current working directory to throw `ERR_WORKER_CANNOT_CHANGE_CWD` when the
// Vitest pool strategy spawns multiple threads. In the real CLI this
// restriction does not apply (the program runs on the main thread), so we
// polyfill the call here to keep the behaviour consistent across execution
// environments.
import path from "node:path";
// Cache the initial CWD so we can emulate subsequent changes.
let currentCwd = process.cwd();
// Replace `process.chdir` with a version that *simulates* the directory change
// instead of delegating to Nodes native implementation when running inside a
// worker. The polyfill updates `process.cwd()` and the `PWD` environment
// variable so that code relying on either continues to work as expected.
// eslint-disable-next-line no-global-assign, @typescript-eslint/ban-ts-comment
// @ts-ignore Nodes types mark `process` as `Readonly<Process>` but runtime
// mutation is perfectly fine.
process.chdir = function mockedChdir(targetDir) {
// Resolve the new directory against the current working directory just like
// the real implementation would.
currentCwd = path.resolve(currentCwd, targetDir);
// Keep `process.env.PWD` in sync many libraries rely on it.
process.env.PWD = currentCwd;
};
// Override `process.cwd` so it returns our emulated value.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
process.cwd = function mockedCwd() {
return currentCwd;
};

View File

@@ -0,0 +1,28 @@
import React from "react";
import { render } from "ink-testing-library";
import stripAnsi from "strip-ansi";
export function renderTui(ui) {
const { stdin, lastFrame, unmount, cleanup } = render(ui, {
exitOnCtrlC: false,
});
// Some libraries assume these methods exist on TTY streams; add noops.
if (stdin && typeof stdin.ref !== "function") {
// @ts-ignore
stdin.ref = () => {};
}
if (stdin && typeof stdin.unref !== "function") {
// @ts-ignore
stdin.unref = () => {};
}
const lastFrameStripped = () => stripAnsi(lastFrame() ?? "");
async function flush() {
// wait one tick for Ink to process
await new Promise((resolve) => setTimeout(resolve, 0));
}
return { stdin, lastFrame, lastFrameStripped, unmount, cleanup, flush };
}

View File

@@ -11,7 +11,7 @@
],
"types": ["node"],
"baseUrl": "./",
"resolveJsonModule": false, // ESM doesn't yet support JSON modules.
"resolveJsonModule": true, // ESM doesn't yet support JSON modules.
"jsx": "react",
"declaration": true,
"newLine": "lf",
@@ -30,5 +30,5 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
"include": ["src", "tests", "bin"]
}

View File

@@ -1,4 +1,33 @@
import { defineConfig } from 'vite';
import { defineConfig } from "vite";
// Provide a stub Vite config in the CLI package to avoid resolving a parent-level vite.config.js
export default defineConfig({});
/**
* Vite configuration used by the Codex CLI package. The build process itself
* doesnt rely on Vites bundling features we only ship this file so that
* Vitest can pick it up when executing the unittest suite. The only custom
* logic we currently inject is a *test* configuration block that registers a
* small setup script executed in each worker thread before any test files are
* loaded. That script polyfills `process.chdir()` which is disallowed inside
* Node.js workers as of v22 and would otherwise throw when some tests attempt
* to change the working directory.
*/
export default defineConfig({
test: {
// Execute tests inside worker threads but force Vitest to spawn *only one*
// worker. This keeps the environment isolation that some components
// depend on while avoiding a `tinypool` recursion bug that occasionally
// triggers when multiple workers are used.
pool: "threads",
poolOptions: {
threads: {
minThreads: 1,
maxThreads: 1,
},
},
/**
* Register the setup file. We use a relative path so that Vitest resolves
* it against the project root irrespective of where the CLI is executed.
*/
setupFiles: ["./tests/test-setup.js"],
},
});

1
codex-cli/vitest.json Normal file
View File

@@ -0,0 +1 @@
{"numTotalTestSuites":3,"numPassedTestSuites":0,"numFailedTestSuites":3,"numPendingTestSuites":0,"numTotalTests":0,"numPassedTests":0,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1745144446038,"success":false,"testResults":[{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/attachment-preview.test.tsx"},{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/backspace-delete-image.test.tsx"},{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/terminal-chat-input-compact.test.tsx"}]}

359
package-lock.json generated
View File

@@ -1,359 +0,0 @@
{
"name": "codex",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"git-cliff": "^2.8.0",
"prettier": "^3.5.3"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/git-cliff": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff/-/git-cliff-2.8.0.tgz",
"integrity": "sha512-iKF5QTXAb9+iVvmu5HpnMPWYw7fs74xkpAaRbSf29+dZaMTTNRIUST/y+Ir2S1bDUWWJNjXlwT9ZT62JuYLQnA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"execa": "^8.0.1"
},
"bin": {
"git-cliff": "lib/cli/cli.js"
},
"engines": {
"node": ">=18.19 || >=20.6 || >=21"
},
"optionalDependencies": {
"git-cliff-darwin-arm64": "2.8.0",
"git-cliff-darwin-x64": "2.8.0",
"git-cliff-linux-arm64": "2.8.0",
"git-cliff-linux-x64": "2.8.0",
"git-cliff-windows-arm64": "2.8.0",
"git-cliff-windows-x64": "2.8.0"
}
},
"node_modules/git-cliff-darwin-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-darwin-arm64/-/git-cliff-darwin-arm64-2.8.0.tgz",
"integrity": "sha512-rurUV2d1Z2n+c2+wUrO0gZaFb3c1G+ej0bPfKTPfde/CblxiysMkh+4dz23NrVbc8IlS5rSYv/JFGVaVSBNJRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/git-cliff-darwin-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-darwin-x64/-/git-cliff-darwin-x64-2.8.0.tgz",
"integrity": "sha512-Wtj+FGWZBWmeYUAGlkfz7QPz4+VVxxDPMhQ/7iwKVA3iryIX0slGfzYpqMurEFnTAMr0r+4IU3Q4O/ib7iUscg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/git-cliff-linux-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-linux-arm64/-/git-cliff-linux-arm64-2.8.0.tgz",
"integrity": "sha512-k4RdfMdORXyefznWlQb+7wDgo7XgQF9qg8hJC34bwyJK2sODirrGau3uTx1/9Fi37g+pAOM7wM+LYppHCTZ2bQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/git-cliff-linux-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-linux-x64/-/git-cliff-linux-x64-2.8.0.tgz",
"integrity": "sha512-FcWX4GHgodYrQlZR03fzooanStgR03JNWvyaMQB1asplQ18nlziK2UyA+PESCIxOQmeLXauqoCApfzmdtp5myg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/git-cliff-windows-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-windows-arm64/-/git-cliff-windows-arm64-2.8.0.tgz",
"integrity": "sha512-GJSrqmBVTbMtBJI3/YCDxLviZZDgYgnKqYgquBk2u2AELAnnuWFnVFQ7ZEBUqgFF2UJu9EdV2Nv6MV8d/wnP0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/git-cliff-windows-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-windows-x64/-/git-cliff-windows-x64-2.8.0.tgz",
"integrity": "sha512-8jl0YMXPYjUmVygUEeQ4wf1zte3Rv8LPq1sIklUKl80XE4g2Gm/8EIWbKpUPLQH6IncRwepY6VuMgpVpPXbwNw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
}
}
}

View File

@@ -1,13 +1,45 @@
{
"name": "codex-monorepo",
"private": true,
"description": "Tools for repo-wide maintenance.",
"scripts": {
"release": "cd codex-cli && npm run release",
"release": "pnpm --filter @openai/codex run release",
"format": "prettier --check *.json *.md .github/workflows/*.yml",
"format:fix": "prettier --write *.json *.md .github/workflows/*.yml",
"changelog": "git-cliff --config cliff.toml --output CHANGELOG.ignore.md $LAST_RELEASE_TAG..HEAD"
"build": "pnpm --filter @openai/codex run build",
"test": "pnpm --filter @openai/codex run test",
"lint": "pnpm --filter @openai/codex run lint",
"typecheck": "pnpm --filter @openai/codex run typecheck",
"changelog": "git-cliff --config cliff.toml --output CHANGELOG.ignore.md $LAST_RELEASE_TAG..HEAD",
"prepare": "husky",
"husky:add": "husky add"
},
"devDependencies": {
"git-cliff": "^2.8.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3"
}
},
"resolutions": {
"braces": "^3.0.3",
"micromatch": "^4.0.8",
"semver": "^7.7.1"
},
"overrides": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=22",
"pnpm": ">=9.0.0"
},
"lint-staged": {
"*.json": "prettier --write",
"*.md": "prettier --write",
".github/workflows/*.yml": "prettier --write",
"**/*.{js,ts,tsx}": [
"cd codex-cli && pnpm run lint",
"cd codex-cli && pnpm run typecheck"
]
},
"packageManager": "pnpm@10.8.1"
}

4936
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
packages:
- codex-cli
- docs
- packages/*
ignoredBuiltDependencies:
- esbuild