mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
10 Commits
remove/doc
...
image-pick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5481bf0cb4 | ||
|
|
41ef530683 | ||
|
|
1a0f4a5e93 | ||
|
|
28410d62af | ||
|
|
379b023a7f | ||
|
|
b1cef74d8c | ||
|
|
1e0a7cc313 | ||
|
|
2bcc15a839 | ||
|
|
d7d2c3f1e7 | ||
|
|
35148c2ba9 |
1
.github/ISSUE_TEMPLATE/2-bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/2-bug-report.yml
vendored
@@ -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: |
|
||||
|
||||
71
.github/workflows/ci.yml
vendored
71
.github/workflows/ci.yml
vendored
@@ -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: Type‑check (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
13
.gitignore
vendored
@@ -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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
4
.npmrc
Normal file
4
.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
node-linker=hoisted
|
||||
prefer-workspace-packages=true
|
||||
@@ -1,2 +1,3 @@
|
||||
/codex-cli/dist
|
||||
/codex-cli/node_modules
|
||||
pnpm-lock.yaml
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
70
PNPM.md
Normal 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
|
||||
41
README.md
41
README.md
@@ -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 super‑fast feedback.
|
||||
- Use `pnpm test:watch` during development for super‑fast feedback.
|
||||
- We use **Vitest** for unit tests, **ESLint** + **Prettier** for style, and **TypeScript** for type‑checking.
|
||||
- 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
|
||||
|
||||
# Type‑check 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`
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run tests and type checking before pushing
|
||||
npm test && npm run typecheck
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
@@ -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
27
codex-cli/bin/codex.js
Executable 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);
|
||||
}
|
||||
})();
|
||||
7372
codex-cli/package-lock.json
generated
7372
codex-cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
212
codex-cli/src/components/chat/image-picker-overlay.tsx
Normal file
212
codex-cli/src/components/chat/image-picker-overlay.tsx
Normal 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
|
||||
// life‑time of the overlay. Depending on the environment a single <Enter>
|
||||
// key‑press 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 low‑level `readable` event that Ink’s
|
||||
// built‑in `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 SelectInput’s `onSelect` callback (it fires synchronously when the
|
||||
// user presses Return – which is exactly what the ink‑testing‑library 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 SelectInput’s
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -259,25 +259,47 @@ const MultilineTextEditorInner = (
|
||||
console.log("[MultilineTextEditor] event", { input, key });
|
||||
}
|
||||
|
||||
// 1) CSI‑u / 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: bit‑1 (value 2) is Shift. Everything >1 that
|
||||
// isn't exactly 1 means some modifier was held. We treat *shift
|
||||
// present* (2,4,6,8) as newline; plain (1) as submit.
|
||||
// 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` – bit‑2 (value 4) indicates
|
||||
// that Ctrl was held. We avoid the `&` bitwise operator (disallowed
|
||||
// by our ESLint config) by using arithmetic instead.
|
||||
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
|
||||
if (hasCtrl) {
|
||||
if (onSubmit) {
|
||||
onSubmit(buffer.current.getText());
|
||||
}
|
||||
} else {
|
||||
// Any variant without Ctrl just inserts newline (Shift, Alt, none)
|
||||
buffer.current.newline();
|
||||
}
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 mode‑2 variant above so
|
||||
// that Shift+Enter (newline) / Ctrl+Enter (submit) work regardless
|
||||
// of the user’s 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 elapsed‑time 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>
|
||||
|
||||
@@ -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 raw‑data 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
|
||||
// higher‑level `useInput` hook does *not* emit a callback for this
|
||||
// control sequence when running under the ink‑testing‑library, 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` isn’t 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 early‑return – 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 pre‑filled 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: 
|
||||
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 don’t 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 elapsed‑seconds 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 isn’t
|
||||
// recomputed on every re‑render 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(); // re‑render after teardown too
|
||||
};
|
||||
}, [
|
||||
model,
|
||||
config,
|
||||
approvalPolicy,
|
||||
requestConfirmation,
|
||||
additionalWritableRoots,
|
||||
]);
|
||||
// We intentionally omit 'approvalPolicy' and 'confirmationPrompt' from the deps
|
||||
// so switching modes or showing confirmation dialogs doesn’t 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>
|
||||
);
|
||||
|
||||
@@ -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:{" "}
|
||||
|
||||
16
codex-cli/src/components/chat/terminal-inline-image.tsx
Normal file
16
codex-cli/src/components/chat/terminal-inline-image.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
93
codex-cli/src/components/diff-overlay.tsx
Normal file
93
codex-cli/src/components/diff-overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -153,6 +153,78 @@ function TextInput({
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Support Shift+Enter / Ctrl+Enter from terminals that have
|
||||
// modifyOtherKeys enabled. Such terminals encode the key‑combo 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* (CSI‑u, 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 {
|
||||
// CSI‑u (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 ||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
82
codex-cli/src/utils/bug-report.ts
Normal file
82
codex-cli/src/utils/bug-report.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ResponseItem,
|
||||
ResponseOutputItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
|
||||
/**
|
||||
* Build a GitHub issues‐new URL that pre‑fills the Codex 2‑bug‑report.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()}`;
|
||||
}
|
||||
143
codex-cli/src/utils/check-updates.ts
Normal file
143
codex-cli/src/utils/check-updates.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
36
codex-cli/src/utils/extract-applied-patches.ts
Normal file
36
codex-cli/src/utils/extract-applied-patches.ts
Normal 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");
|
||||
}
|
||||
29
codex-cli/src/utils/get-diff.ts
Normal file
29
codex-cli/src/utils/get-diff.ts
Normal 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. `rev‑parse` exits
|
||||
// with a non‑zero status code if not.
|
||||
execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" });
|
||||
|
||||
// If the above call didn’t 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 we’re not inside a repository.
|
||||
return { isGitRepo: false, diff: "" };
|
||||
}
|
||||
}
|
||||
79
codex-cli/src/utils/image-detector.ts
Normal file
79
codex-cli/src/utils/image-detector.ts
Normal 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. 
|
||||
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 
|
||||
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 };
|
||||
}
|
||||
54
codex-cli/src/utils/image-picker-utils.ts
Normal file
54
codex-cli/src/utils/image-picker-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 data‑urls → 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 data‑url.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
32
codex-cli/src/utils/slash-commands.ts
Normal file
32
codex-cli/src/utils/slash-commands.ts
Normal 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)",
|
||||
},
|
||||
];
|
||||
12
codex-cli/tests/__snapshots__/check-updates.test.ts.snap
Normal file
12
codex-cli/tests/__snapshots__/check-updates.test.ts.snap
Normal 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. │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
92
codex-cli/tests/attachment-preview.test.tsx
Normal file
92
codex-cli/tests/attachment-preview.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
87
codex-cli/tests/backspace-delete-image.test.tsx
Normal file
87
codex-cli/tests/backspace-delete-image.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
117
codex-cli/tests/check-updates.test.ts
Normal file
117
codex-cli/tests/check-updates.test.ts
Normal 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 in‑memory 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
156
codex-cli/tests/drag-drop-attach-image.test.tsx
Normal file
156
codex-cli/tests/drag-drop-attach-image.test.tsx
Normal 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 Vitest’s 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 it’s 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);
|
||||
});
|
||||
});
|
||||
25
codex-cli/tests/extract-image-paths.test.ts
Normal file
25
codex-cli/tests/extract-image-paths.test.ts
Normal 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  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");
|
||||
});
|
||||
});
|
||||
74
codex-cli/tests/image-overlay.test.tsx
Normal file
74
codex-cli/tests/image-overlay.test.tsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
79
codex-cli/tests/inline-image.test.tsx
Normal file
79
codex-cli/tests/inline-image.test.tsx
Normal 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 (mid‑conversation)", 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 system‑level warning are covered in
|
||||
// higher‑level suites. This unit file focuses on createInputItem behaviour.
|
||||
});
|
||||
47
codex-cli/tests/input-utils.test.ts
Normal file
47
codex-cli/tests/input-utils.test.ts
Normal 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]",
|
||||
});
|
||||
});
|
||||
});
|
||||
49
codex-cli/tests/multiline-shift-enter-mod1.test.tsx
Normal file
49
codex-cli/tests/multiline-shift-enter-mod1.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
|
||||
39
codex-cli/tests/slash-commands.test.ts
Normal file
39
codex-cli/tests/slash-commands.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
36
codex-cli/tests/test-setup.js
Normal file
36
codex-cli/tests/test-setup.js
Normal 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 Node’s 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 – Node’s 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;
|
||||
};
|
||||
28
codex-cli/tests/ui-test-helpers.js
Normal file
28
codex-cli/tests/ui-test-helpers.js
Normal 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 no‑ops.
|
||||
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 };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* doesn’t rely on Vite’s bundling features – we only ship this file so that
|
||||
* Vitest can pick it up when executing the unit‑test 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
1
codex-cli/vitest.json
Normal 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
359
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
package.json
38
package.json
@@ -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
4936
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- codex-cli
|
||||
- docs
|
||||
- packages/*
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
Reference in New Issue
Block a user