This commit is contained in:
Eason Goodale
2025-04-20 03:47:48 -07:00
parent b1cef74d8c
commit 379b023a7f
64 changed files with 6753 additions and 8117 deletions
+1
View File
@@ -26,6 +26,7 @@ body:
label: Which model were you using?
description: Like `gpt-4.1`, `o4-mini`, `o3`, etc.
- type: input
id: platform
attributes:
label: What platform is your computer?
description: |
+41 -30
View File
@@ -19,40 +19,51 @@ jobs:
with:
node-version: 22
# Run codex-cli/ tasks first because they are higher signal.
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
run_install: false
- name: Install dependencies (codex-cli)
working-directory: codex-cli
run: npm ci
- name: Check formatting (codex-cli)
working-directory: codex-cli
run: npm run format
- name: Run tests (codex-cli)
working-directory: codex-cli
run: npm run test
- name: Lint (codex-cli)
working-directory: codex-cli
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
npm run lint -- \
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.store_path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
# Run all tasks using workspace filters
- name: Check TypeScript code formatting
working-directory: codex-cli
run: pnpm run format
- name: Check Markdown and config file formatting
run: pnpm run format
- name: Run tests
run: pnpm run test
- name: Lint
run: |
pnpm --filter @openai/codex exec -- eslint src tests --ext ts --ext tsx \
--report-unused-disable-directives \
--rule "no-console:error" \
--rule "no-debugger:error" \
--max-warnings=-1
- name: 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
+11 -2
View File
@@ -1,5 +1,11 @@
# deps
# Node.js dependencies
node_modules
.pnpm-store
.pnpm-debug.log
# Keep pnpm-lock.yaml
!pnpm-lock.yaml
# build
dist/
@@ -20,6 +26,10 @@ result
*.swp
*~
# cli tools
CLAUDE.md
.claude/
# caches
.cache/
.turbo/
@@ -61,9 +71,8 @@ Icon?
# Unwanted package managers
.yarn/
yarn.lock
pnpm-lock.yaml
# release
package.json-e
session.ts-e
CHANGELOG.ignore.md
CHANGELOG.ignore.md
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+4
View File
@@ -0,0 +1,4 @@
shamefully-hoist=true
strict-peer-dependencies=false
node-linker=hoisted
prefer-workspace-packages=true
+1
View File
@@ -1,2 +1,3 @@
/codex-cli/dist
/codex-cli/node_modules
pnpm-lock.yaml
+16 -2
View File
@@ -2,7 +2,21 @@
You can install any of these versions: `npm install -g codex@version`
## 0.1.2504172351
## `0.1.2504181820`
### 🚀 Features
- Add `/bug` report command (#312)
- Notify when a newer version is available (#333)
### 🐛 Bug Fixes
- Update context left display logic in TerminalChatInput component (#307)
- Improper spawn of sh on Windows Powershell (#318)
- `/bug` report command, thinking indicator (#381)
- Include pnpm lock file (#377)
## `0.1.2504172351`
### 🚀 Features
@@ -14,7 +28,7 @@ You can install any of these versions: `npm install -g codex@version`
- Raw-exec-process-group.test improve reliability and error handling (#280)
- Canonicalize the writeable paths used in seatbelt policy (#275)
## 0.1.2504172304
## `0.1.2504172304`
### 🚀 Features
+70
View File
@@ -0,0 +1,70 @@
# Migration to pnpm
This project has been migrated from npm to pnpm to improve dependency management and developer experience.
## Why pnpm?
- **Faster installation**: pnpm is significantly faster than npm and yarn
- **Disk space savings**: pnpm uses a content-addressable store to avoid duplication
- **Phantom dependency prevention**: pnpm creates a strict node_modules structure
- **Native workspaces support**: simplified monorepo management
## How to use pnpm
### Installation
```bash
# Global installation of pnpm
npm install -g pnpm@10.8.1
# Or with corepack (available with Node.js 22+)
corepack enable
corepack prepare pnpm@10.8.1 --activate
```
### Common commands
| npm command | pnpm equivalent |
| --------------- | ---------------- |
| `npm install` | `pnpm install` |
| `npm run build` | `pnpm run build` |
| `npm test` | `pnpm test` |
| `npm run lint` | `pnpm run lint` |
### Workspace-specific commands
| Action | Command |
| ------------------------------------------ | ---------------------------------------- |
| Run a command in a specific package | `pnpm --filter @openai/codex run build` |
| Install a dependency in a specific package | `pnpm --filter @openai/codex add lodash` |
| Run a command in all packages | `pnpm -r run test` |
## Monorepo structure
```
codex/
├── pnpm-workspace.yaml # Workspace configuration
├── .npmrc # pnpm configuration
├── package.json # Root dependencies and scripts
├── codex-cli/ # Main package
│ └── package.json # codex-cli specific dependencies
└── docs/ # Documentation (future package)
```
## Configuration files
- **pnpm-workspace.yaml**: Defines the packages included in the monorepo
- **.npmrc**: Configures pnpm behavior
- **Root package.json**: Contains shared scripts and dependencies
## CI/CD
CI/CD workflows have been updated to use pnpm instead of npm. Make sure your CI environments use pnpm 10.8.1 or higher.
## Known issues
If you encounter issues with pnpm, try the following solutions:
1. Remove the `node_modules` folder and `pnpm-lock.yaml` file, then run `pnpm install`
2. Make sure you're using pnpm 10.8.1 or higher
3. Verify that Node.js 22 or higher is installed
+30 -11
View File
@@ -244,6 +244,8 @@ npm install -g @openai/codex
yarn global add @openai/codex
# or
bun install -g @openai/codex
# or
pnpm add -g @openai/codex
```
</details>
@@ -256,9 +258,12 @@ bun install -g @openai/codex
git clone https://github.com/openai/codex.git
cd codex/codex-cli
# Enable corepack
corepack enable
# Install dependencies and build
npm install
npm run build
pnpm install
pnpm build
# Get the usage and the options
node ./dist/cli.js --help
@@ -267,7 +272,7 @@ node ./dist/cli.js --help
node ./dist/cli.js
# Or link the command globally for convenience
npm link
pnpm link
```
</details>
@@ -276,13 +281,27 @@ npm link
## Configuration
Codex looks for config files in **`~/.codex/`**.
Codex looks for config files in **`~/.codex/`** (either YAML or JSON format).
```yaml
# ~/.codex/config.yaml
model: o4-mini # Default model
approvalMode: suggest # or auto-edit, full-auto
fullAutoErrorMode: ask-user # or ignore-and-continue
notify: true # Enable desktop notifications for responses
safeCommands:
- npm test # Automatically approve npm test
- yarn lint # Automatically approve yarn lint
```
```json
// ~/.codex/config.json
{
"model": "o4-mini",
"approvalMode": "suggest",
"fullAutoErrorMode": "ask-user",
"notify": true
}
```
You can also define custom instructions:
@@ -376,7 +395,7 @@ More broadly we welcome contributions – whether you are opening your very firs
- Create a _topic branch_ from `main` – e.g. `feat/interactive-prompt`.
- Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs.
- Use `npm run test:watch` during development for 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`
---
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run lint-staged to check files that are about to be committed
npm run pre-commit
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run tests and type checking before pushing
npm test && npm run typecheck
-9
View File
@@ -1,9 +0,0 @@
{
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
]
}
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env sh
# resolve script path in case of symlink
SOURCE="$0"
while [ -h "$SOURCE" ]; do
DIR=$(dirname "$SOURCE")
SOURCE=$(readlink "$SOURCE")
case "$SOURCE" in
/*) ;; # absolute path
*) SOURCE="$DIR/$SOURCE" ;; # relative path
esac
done
DIR=$(cd "$(dirname "$SOURCE")" && pwd)
if command -v node >/dev/null 2>&1; then
exec node "$DIR/../dist/cli.js" "$@"
elif command -v bun >/dev/null 2>&1; then
exec bun "$DIR/../dist/cli.js" "$@"
else
echo "Error: node or bun is required to run codex" >&2
exit 1
fi
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Unified entry point for Codex CLI on all platforms
// Dynamically loads the compiled ESM bundle in dist/cli.js
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
// Determine this script's directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve the path to the compiled CLI bundle
const cliPath = path.resolve(__dirname, '../dist/cli.js');
const cliUrl = pathToFileURL(cliPath).href;
// Load and execute the CLI
(async () => {
try {
await import(cliUrl);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-undef
process.exit(1);
}
})();
-7372
View File
File diff suppressed because it is too large Load Diff
+9 -17
View File
@@ -1,9 +1,9 @@
{
"name": "@openai/codex",
"version": "0.1.2504172351",
"version": "0.1.2504181820",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex"
"codex": "bin/codex.js"
},
"type": "module",
"engines": {
@@ -22,10 +22,8 @@
"build:dev": "NODE_ENV=development node build.mjs --dev && NODE_OPTIONS=--enable-source-maps node dist/cli-dev.js",
"release:readme": "cp ../README.md ./README.md",
"release:version": "TS=$(date +%y%m%d%H%M) && sed -E -i'' -e \"s/\\\"0\\.1\\.[0-9]{10}\\\"/\\\"0.1.${TS}\\\"/g\" package.json src/utils/session.ts",
"release:build-and-publish": "npm run build && npm publish",
"release": "npm run release:readme && npm run release:version && npm install && npm run release:build-and-publish",
"prepare": "husky",
"pre-commit": "lint-staged"
"release:build-and-publish": "pnpm run build && npm publish",
"release": "pnpm run release:readme && pnpm run release:version && pnpm install && pnpm run release:build-and-publish"
},
"files": [
"README.md",
@@ -47,7 +45,7 @@
"marked-terminal": "^7.3.0",
"meow": "^13.2.0",
"open": "^10.1.0",
"openai": "^4.89.0",
"openai": "^4.95.1",
"react": "^18.2.0",
"shell-quote": "^1.8.2",
"strip-ansi": "^7.1.0",
@@ -62,8 +60,10 @@
"@types/marked-terminal": "^6.1.1",
"@types/react": "^18.0.32",
"@types/shell-quote": "^1.7.5",
"@types/which": "^3.0.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"boxen": "^8.0.1",
"esbuild": "^0.25.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.32.2",
@@ -71,21 +71,13 @@
"eslint-plugin-react-refresh": "^0.4.19",
"husky": "^9.1.7",
"ink-testing-library": "^3.0.0",
"lint-staged": "^15.5.1",
"prettier": "^2.8.7",
"punycode": "^2.3.1",
"ts-node": "^10.9.1",
"typescript": "^5.0.3",
"vitest": "^3.0.9",
"whatwg-url": "^14.2.0"
},
"resolutions": {
"braces": "^3.0.3",
"micromatch": "^4.0.8",
"semver": "^7.7.1"
},
"overrides": {
"punycode": "^2.3.1"
"whatwg-url": "^14.2.0",
"which": "^5.0.0"
},
"repository": {
"type": "git",
+15 -5
View File
@@ -23,6 +23,16 @@ fi
WORK_DIR=$(realpath "$WORK_DIR")
# Generate a unique container name based on the normalized work directory
CONTAINER_NAME="codex_$(echo "$WORK_DIR" | sed 's/\//_/g' | sed 's/[^a-zA-Z0-9_-]//g')"
# Define cleanup to remove the container on script exit, ensuring no leftover containers
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
# Trap EXIT to invoke cleanup regardless of how the script terminates
trap cleanup EXIT
# Ensure a command is provided.
if [ "$#" -eq 0 ]; then
echo "Usage: $0 [--work_dir directory] \"COMMAND\""
@@ -35,11 +45,11 @@ if [ -z "$WORK_DIR" ]; then
exit 1
fi
# Remove any existing container named 'codex'.
docker rm -f codex 2>/dev/null || true
# Kill any existing container for the working directory using cleanup(), centralizing removal logic.
cleanup
# Run the container with the specified directory mounted at the same path inside the container.
docker run --name codex -d \
docker run --name "$CONTAINER_NAME" -d \
-e OPENAI_API_KEY \
--cap-add=NET_ADMIN \
--cap-add=NET_RAW \
@@ -48,7 +58,7 @@ docker run --name codex -d \
sleep infinity
# Initialize the firewall inside the container.
docker exec codex bash -c "sudo /usr/local/bin/init_firewall.sh"
docker exec "$CONTAINER_NAME" bash -c "sudo /usr/local/bin/init_firewall.sh"
# Execute the provided command in the container, ensuring it runs in the work directory.
# We use a parameterized bash command to safely handle the command and directory.
@@ -57,4 +67,4 @@ quoted_args=""
for arg in "$@"; do
quoted_args+=" $(printf '%q' "$arg")"
done
docker exec -it codex bash -c "cd \"/app$WORK_DIR\" && codex --full-auto ${quoted_args}"
docker exec -it "$CONTAINER_NAME" bash -c "cd \"/app$WORK_DIR\" && codex --full-auto ${quoted_args}"
+19
View File
@@ -4,6 +4,7 @@ import {
identify_files_added,
identify_files_needed,
} from "./utils/agent/apply-patch";
import { loadConfig } from "./utils/config";
import * as path from "path";
import { parse } from "shell-quote";
@@ -296,6 +297,24 @@ export function isSafeCommand(
): SafeCommandReason | null {
const [cmd0, cmd1, cmd2, cmd3] = command;
const config = loadConfig();
if (config.safeCommands && Array.isArray(config.safeCommands)) {
for (const safe of config.safeCommands) {
// safe: "npm test" → ["npm", "test"]
const safeArr = typeof safe === "string" ? safe.trim().split(/\s+/) : [];
if (
safeArr.length > 0 &&
safeArr.length <= command.length &&
safeArr.every((v, i) => v === command[i])
) {
return {
reason: "User-defined safe command",
group: "User config",
};
}
}
}
switch (cmd0) {
case "cd":
return {
+48 -9
View File
@@ -17,6 +17,7 @@ import { AgentLoop } from "./utils/agent/agent-loop";
import { initLogger } from "./utils/agent/log";
import { ReviewDecision } from "./utils/agent/review";
import { AutoApprovalMode } from "./utils/auto-approval-mode";
import { checkForUpdates } from "./utils/check-updates";
import {
loadConfig,
PRETTY_PRINT,
@@ -70,6 +71,9 @@ const cli = meow(
--full-stdout Do not truncate stdout/stderr from command outputs
--notify Enable desktop notifications for responses
--flex-mode Use "flex-mode" processing mode for the request (only supported
with models o3 and o4-mini)
Dangerous options
--dangerously-auto-approve-everything
Skip all confirmation prompts and execute commands without
@@ -139,6 +143,11 @@ const cli = meow(
type: "string",
description: "Path to a markdown file to include as project doc",
},
flexMode: {
type: "boolean",
description:
"Enable the flex-mode service tier (only supported by models o3 and o4-mini)",
},
fullStdout: {
type: "boolean",
description:
@@ -249,9 +258,29 @@ config = {
apiKey,
...config,
model: model ?? config.model,
flexMode: Boolean(cli.flags.flexMode),
notify: Boolean(cli.flags.notify),
};
// Check for updates after loading config
// This is important because we write state file in the config dir
await checkForUpdates().catch();
// ---------------------------------------------------------------------------
// --flex-mode validation (only allowed for o3 and o4-mini)
// ---------------------------------------------------------------------------
if (cli.flags.flexMode) {
const allowedFlexModels = new Set(["o3", "o4-mini"]);
if (!allowedFlexModels.has(config.model)) {
// eslint-disable-next-line no-console
console.error(
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
`Current model: '${config.model}'.`,
);
process.exit(1);
}
}
if (!(await isModelSupportedForResponses(config.model))) {
// eslint-disable-next-line no-console
console.error(
@@ -298,9 +327,6 @@ const additionalWritableRoots: ReadonlyArray<string> = (
// If we are running in --quiet mode, do that and exit.
const quietMode = Boolean(cli.flags.quiet);
const autoApproveEverything = Boolean(
cli.flags.dangerouslyAutoApproveEverything,
);
const fullStdout = Boolean(cli.flags.fullStdout);
if (quietMode) {
@@ -312,12 +338,19 @@ if (quietMode) {
);
process.exit(1);
}
// Determine approval policy for quiet mode based on flags
const quietApprovalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
await runQuietMode({
prompt: prompt as string,
imagePaths: imagePaths || [],
approvalPolicy: autoApproveEverything
? AutoApprovalMode.FULL_AUTO
: AutoApprovalMode.SUGGEST,
approvalPolicy: quietApprovalPolicy,
additionalWritableRoots,
config,
});
@@ -335,14 +368,15 @@ if (quietMode) {
// it is more dangerous than --fullAuto we deliberately give it lower
// priority so a user specifying both flags still gets the safer behaviour.
// 3. --autoEdit – automatically approve edits, but prompt for commands.
// 4. Default – suggest mode (prompt for everything).
// 4. config.approvalMode - use the approvalMode setting from ~/.codex/config.json.
// 5. Default – suggest mode (prompt for everything).
const approvalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: AutoApprovalMode.SUGGEST;
: config.approvalMode || AutoApprovalMode.SUGGEST;
preloadModels();
@@ -440,7 +474,12 @@ async function runQuietMode({
getCommandConfirmation: (
_command: Array<string>,
): Promise<CommandConfirmation> => {
return Promise.resolve({ review: ReviewDecision.NO_CONTINUE });
// In quiet mode, default to NO_CONTINUE, except when in full-auto mode
const reviewDecision =
approvalPolicy === AutoApprovalMode.FULL_AUTO
? ReviewDecision.YES
: ReviewDecision.NO_CONTINUE;
return Promise.resolve({ review: reviewDecision });
},
onLastResponseId: () => {
/* intentionally ignored in quiet mode */
@@ -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>
@@ -11,18 +11,20 @@ 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";
// 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";
import React, { useCallback, useState, Fragment, useEffect } from "react";
import path from "node:path";
import fs from "fs/promises";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import Spinner from "../vendor/ink-spinner.js";
import TextInput from "../vendor/ink-text-input.js";
import { useInterval } from "use-interval";
// Internal imports
@@ -49,9 +51,12 @@ export default function TerminalChatInput({
openModelOverlay,
openApprovalOverlay,
openHelpOverlay,
openDiffOverlay,
onCompact,
interruptAgent,
active,
thinkingSeconds,
items = [],
}: {
isNew: boolean;
loading: boolean;
@@ -69,10 +74,17 @@ 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);
@@ -100,6 +112,7 @@ export default function TerminalChatInput({
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)
@@ -161,17 +174,109 @@ 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 image picker overlay is open delegate all keystrokes to it.
// 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; // ignore here; overlay has its own handlers
return; // overlay has its own handlers
}
// Slash command navigation: up/down to select, Tab to cycle, Enter to run.
if (!confirmationPrompt && !loading && input.trim().startsWith("/")) {
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"]) {
@@ -271,6 +376,16 @@ export default function TerminalChatInput({
const onSubmit = useCallback(
async (value: string) => {
const inputValue = value.trim();
// 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;
}
if (!inputValue) {
return;
}
@@ -287,6 +402,12 @@ export default function TerminalChatInput({
return;
}
if (inputValue === "/diff") {
setInput("");
openDiffOverlay();
return;
}
if (inputValue === "/compact") {
setInput("");
onCompact();
@@ -358,6 +479,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.
@@ -497,9 +682,12 @@ export default function TerminalChatInput({
openApprovalOverlay,
openModelOverlay,
openHelpOverlay,
openDiffOverlay,
attachedImages,
history, // Add history to the dependency array
history,
onCompact,
skipNextSubmit,
items,
],
);
@@ -508,7 +696,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}
/>
);
}
@@ -570,6 +762,7 @@ export default function TerminalChatInput({
<TerminalChatInputThinking
onInterrupt={interruptAgent}
active={active}
thinkingSeconds={thinkingSeconds}
/>
) : (
<Box paddingX={1}>
@@ -606,6 +799,25 @@ export default function TerminalChatInput({
</Box>
)}
</Box>
{/* Slash command autocomplete suggestions */}
{input.trim().startsWith("/") && (
<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 ? (
@@ -628,7 +840,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">
@@ -648,12 +868,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
@@ -701,10 +951,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
@@ -735,8 +982,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>
+80 -17
View File
@@ -5,7 +5,7 @@ import type { ColorName } from "chalk";
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
import TerminalChatInput from "./terminal-chat-input.js";
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-item.js";
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-command.js";
import {
calculateContextPercentRemaining,
uniqueById,
@@ -19,17 +19,20 @@ import { isLoggingEnabled, log } from "../../utils/agent/log.js";
import { ReviewDecision } from "../../utils/agent/review.js";
import { generateCompactSummary } from "../../utils/compact-summary.js";
import { OPENAI_BASE_URL } from "../../utils/config.js";
import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/extract-applied-patches.js";
import { getGitDiff } from "../../utils/get-diff.js";
import { createInputItem } from "../../utils/input-utils.js";
import { getAvailableModels } from "../../utils/model-utils.js";
import { CLI_VERSION } from "../../utils/session.js";
import { shortCwd } from "../../utils/short-path.js";
import { saveRollout } from "../../utils/storage/save-rollout.js";
import ApprovalModeOverlay from "../approval-mode-overlay.js";
import DiffOverlay from "../diff-overlay.js";
import HelpOverlay from "../help-overlay.js";
import HistoryOverlay from "../history-overlay.js";
import ModelOverlay from "../model-overlay.js";
import { Box, Text } from "ink";
import { exec } from "node:child_process";
import { spawn } from "node:child_process";
import OpenAI from "openai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { inspect } from "util";
@@ -59,6 +62,7 @@ const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
async function generateCommandExplanation(
command: Array<string>,
model: string,
flexMode: boolean,
): Promise<string> {
try {
// Create a temporary OpenAI client
@@ -73,6 +77,7 @@ async function generateCommandExplanation(
// Create a prompt that asks for an explanation with a more detailed system prompt
const response = await oai.chat.completions.create({
model,
...(flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "system",
@@ -142,7 +147,11 @@ export default function TerminalChat({
const handleCompact = async () => {
setLoading(true);
try {
const summary = await generateCompactSummary(items, model);
const summary = await generateCompactSummary(
items,
model,
Boolean(config.flexMode),
);
setItems([
{
id: `compact-${Date.now()}`,
@@ -174,9 +183,16 @@ export default function TerminalChat({
submitConfirmation,
} = useConfirmation();
const [overlayMode, setOverlayMode] = useState<
"none" | "history" | "model" | "approval" | "help"
"none" | "history" | "model" | "approval" | "help" | "diff"
>("none");
// Store the diff text when opening the diff overlay so the view 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:{" "}
@@ -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
View File
@@ -0,0 +1,93 @@
import { Box, Text, useInput } from "ink";
import React, { useState } from "react";
/**
* Simple scrollable view for displaying a diff.
* The component is intentionally lightweight and mirrors the UX of
* HistoryOverlay: Up/Down or j/k to scroll, PgUp/PgDn for paging and Esc to
* close. The caller is responsible for computing the diff text.
*/
export default function DiffOverlay({
diffText,
onExit,
}: {
diffText: string;
onExit: () => void;
}): JSX.Element {
const lines = diffText.length > 0 ? diffText.split("\n") : ["(no changes)"];
const [cursor, setCursor] = useState(0);
// Determine how many rows we can display – similar to HistoryOverlay.
const rows = process.stdout.rows || 24;
const headerRows = 2;
const footerRows = 1;
const maxVisible = Math.max(4, rows - headerRows - footerRows);
useInput((input, key) => {
if (key.escape || input === "q") {
onExit();
return;
}
if (key.downArrow || input === "j") {
setCursor((c) => Math.min(lines.length - 1, c + 1));
} else if (key.upArrow || input === "k") {
setCursor((c) => Math.max(0, c - 1));
} else if (key.pageDown) {
setCursor((c) => Math.min(lines.length - 1, c + maxVisible));
} else if (key.pageUp) {
setCursor((c) => Math.max(0, c - maxVisible));
} else if (input === "g") {
setCursor(0);
} else if (input === "G") {
setCursor(lines.length - 1);
}
});
const firstVisible = Math.min(
Math.max(0, cursor - Math.floor(maxVisible / 2)),
Math.max(0, lines.length - maxVisible),
);
const visible = lines.slice(firstVisible, firstVisible + maxVisible);
// Very small helper to colorize diff lines in a basic way.
function renderLine(line: string, idx: number): JSX.Element {
let color: "green" | "red" | "cyan" | undefined = undefined;
if (line.startsWith("+")) {
color = "green";
} else if (line.startsWith("-")) {
color = "red";
} else if (line.startsWith("@@") || line.startsWith("diff --git")) {
color = "cyan";
}
return (
<Text key={idx} color={color} wrap="truncate-end">
{line === "" ? " " : line}
</Text>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
width={Math.min(120, process.stdout.columns || 120)}
>
<Box paddingX={1}>
<Text bold>Working tree diff ({lines.length} lines)</Text>
</Box>
<Box flexDirection="column" paddingX={1}>
{visible.map((line, idx) => {
return renderLine(line, firstVisible + idx);
})}
</Box>
<Box paddingX={1}>
<Text dimColor>esc Close ↑↓ Scroll PgUp/PgDn g/G First/Last</Text>
</Box>
</Box>
);
}
@@ -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",
+72
View File
@@ -153,6 +153,78 @@ function TextInput({
useInput(
(input, key) => {
// ────────────────────────────────────────────────────────────────
// Support Shift+Enter / Ctrl+Enter from terminals that have
// modifyOtherKeys enabled. Such terminals encode the 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 ||
+36 -1
View File
@@ -496,7 +496,6 @@ export class AgentLoop {
if (this.model.startsWith("o")) {
reasoning = { effort: "high" };
if (this.model === "o3" || this.model === "o4-mini") {
// @ts-expect-error waiting for API type update
reasoning.summary = "auto";
}
}
@@ -517,6 +516,7 @@ export class AgentLoop {
stream: true,
parallel_tool_calls: false,
reasoning,
...(this.config.flexMode ? { service_tier: "flex" } : {}),
tools: [
{
type: "function",
@@ -791,6 +791,41 @@ export class AgentLoop {
this.onLoading(false);
return;
}
// Suppress internal stack on JSON parse failures
if (err instanceof SyntaxError) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Failed to parse streaming response (invalid JSON). Please `/clear` to reset.",
},
],
});
this.onLoading(false);
return;
}
// Handle OpenAI API quota errors
if (
err instanceof Error &&
(err as { code?: string }).code === "insufficient_quota"
) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
},
],
});
this.onLoading(false);
return;
}
throw err;
} finally {
this.currentStream = null;
+10
View File
@@ -1,5 +1,6 @@
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
import type { SpawnOptions } from "child_process";
import type { ParseEntry } from "shell-quote";
import { process_patch } from "./apply-patch.js";
import { SandboxType } from "./sandbox/interface.js";
@@ -8,9 +9,17 @@ import { exec as rawExec } from "./sandbox/raw-exec.js";
import { formatCommandForDisplay } from "../../format-command.js";
import fs from "fs";
import os from "os";
import { parse } from "shell-quote";
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
function requiresShell(cmd: Array<string>): boolean {
return cmd.some((arg) => {
const tokens = parse(arg) as Array<ParseEntry>;
return tokens.some((token) => typeof token === "object" && "op" in token);
});
}
/**
* This function should never return a rejected promise: errors should be
* mapped to a non-zero exit code and the error message should be in stderr.
@@ -33,6 +42,7 @@ export function exec(
const opts: SpawnOptions = {
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
...(requiresShell(cmd) ? { shell: true } : {}),
...(workdir ? { cwd: workdir } : {}),
};
// Merge default writable roots with any user-specified ones.
@@ -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
View 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
View File
@@ -0,0 +1,143 @@
import { CONFIG_DIR } from "./config";
import boxen from "boxen";
import chalk from "chalk";
import * as cp from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import which from "which";
interface UpdateCheckState {
lastUpdateCheck?: string;
}
interface PackageInfo {
current: string;
wanted: string;
latest: string;
dependent: string;
location: string;
}
interface UpdateCheckInfo {
currentVersion: string;
latestVersion: string;
}
const UPDATE_CHECK_FREQUENCY = 1000 * 60 * 60 * 24; // 1 day
export async function getNPMCommandPath(): Promise<string | undefined> {
try {
return await which(process.platform === "win32" ? "npm.cmd" : "npm");
} catch {
return undefined;
}
}
export async function checkOutdated(
npmCommandPath: string,
): Promise<UpdateCheckInfo | undefined> {
return new Promise((resolve, _reject) => {
// TODO: support local installation
// Right now we're using "--global", which only checks global packages.
// But codex might be installed locally — we should check the local version first,
// and only fall back to the global one if needed.
const args = ["outdated", "--global", "--json", "--", "@openai/codex"];
// corepack npm wrapper would automatically update package.json. disable that behavior.
// COREPACK_ENABLE_AUTO_PIN disables the package.json overwrite, and
// COREPACK_ENABLE_PROJECT_SPEC makes the npm view command succeed
// even if packageManager specified a package manager other than npm.
const env = {
...process.env,
COREPACK_ENABLE_AUTO_PIN: "0",
COREPACK_ENABLE_PROJECT_SPEC: "0",
};
let options: cp.ExecFileOptions = { env };
let commandPath = npmCommandPath;
if (process.platform === "win32") {
options = { ...options, shell: true };
commandPath = `"${npmCommandPath}"`;
}
cp.execFile(commandPath, args, options, async (_error, stdout) => {
try {
const { name: packageName } = await import("../../package.json");
const content: Record<string, PackageInfo> = JSON.parse(stdout);
if (!content[packageName]) {
// package not installed or not outdated
resolve(undefined);
return;
}
const currentVersion = content[packageName].current;
const latestVersion = content[packageName].latest;
resolve({ currentVersion, latestVersion });
return;
} catch {
// ignore
}
resolve(undefined);
});
});
}
export async function checkForUpdates(): Promise<void> {
const stateFile = join(CONFIG_DIR, "update-check.json");
let state: UpdateCheckState | undefined;
try {
state = JSON.parse(await readFile(stateFile, "utf8"));
} catch {
// ignore
}
if (
state?.lastUpdateCheck &&
Date.now() - new Date(state.lastUpdateCheck).valueOf() <
UPDATE_CHECK_FREQUENCY
) {
return;
}
const npmCommandPath = await getNPMCommandPath();
if (!npmCommandPath) {
return;
}
const packageInfo = await checkOutdated(npmCommandPath);
await writeState(stateFile, {
...state,
lastUpdateCheck: new Date().toUTCString(),
});
if (!packageInfo) {
return;
}
const updateMessage = `To update, run: ${chalk.cyan(
"npm install -g @openai/codex",
)} to update.`;
const box = boxen(
`\
Update available! ${chalk.red(packageInfo.currentVersion)} → ${chalk.green(
packageInfo.latestVersion,
)}.
${updateMessage}`,
{
padding: 1,
margin: 1,
align: "center",
borderColor: "yellow",
borderStyle: "round",
},
);
// eslint-disable-next-line no-console
console.log(box);
}
async function writeState(stateFilePath: string, state: UpdateCheckState) {
await writeFile(stateFilePath, JSON.stringify(state, null, 2), {
encoding: "utf8",
});
}
+9
View File
@@ -9,9 +9,17 @@ import OpenAI from "openai";
* @param model The model to use for generating the summary
* @returns A concise structured summary string
*/
/**
* Generate a condensed summary of the conversation items.
* @param items The list of conversation items to summarize
* @param model The model to use for generating the summary
* @param flexMode Whether to use the flex-mode service tier
* @returns A concise structured summary string
*/
export async function generateCompactSummary(
items: Array<ResponseItem>,
model: string,
flexMode = false,
): Promise<string> {
const oai = new OpenAI({
apiKey: process.env["OPENAI_API_KEY"],
@@ -44,6 +52,7 @@ export async function generateCompactSummary(
const response = await oai.chat.completions.create({
model,
...(flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "assistant",
+22
View File
@@ -56,6 +56,8 @@ export type StoredConfig = {
saveHistory?: boolean;
sensitivePatterns?: Array<string>;
};
/** User-defined safe commands */
safeCommands?: Array<string>;
};
// Minimal config written on first run. An *empty* model string ensures that
@@ -75,15 +77,21 @@ export type AppConfig = {
apiKey?: string;
model: string;
instructions: string;
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
/** Whether to enable desktop notifications for responses */
notify: boolean;
/** Enable the "flex-mode" processing mode for supported models (o3, o4-mini) */
flexMode?: boolean;
history?: {
maxSize: number;
saveHistory: boolean;
sensitivePatterns: Array<string>;
};
/** User-defined safe commands */
safeCommands?: Array<string>;
};
// ---------------------------------------------------------------------------
@@ -268,6 +276,8 @@ export const loadConfig = (
: DEFAULT_AGENTIC_MODEL),
instructions: combinedInstructions,
notify: storedConfig.notify === true,
approvalMode: storedConfig.approvalMode,
safeCommands: storedConfig.safeCommands ?? [],
};
// -----------------------------------------------------------------------
@@ -345,6 +355,13 @@ export const loadConfig = (
};
}
// Load user-defined safe commands
if (Array.isArray(storedConfig.safeCommands)) {
config.safeCommands = storedConfig.safeCommands.map(String);
} else {
config.safeCommands = [];
}
return config;
};
@@ -376,6 +393,7 @@ export const saveConfig = (
// Create the config object to save
const configToSave: StoredConfig = {
model: config.model,
approvalMode: config.approvalMode,
};
// Add history settings if they exist
@@ -386,6 +404,10 @@ export const saveConfig = (
sensitivePatterns: config.history.sensitivePatterns,
};
}
// Save: User-defined safe commands
if (config.safeCommands && config.safeCommands.length > 0) {
configToSave.safeCommands = config.safeCommands;
}
if (ext === ".yaml" || ext === ".yml") {
writeFileSync(targetPath, dumpYaml(configToSave), "utf-8");
@@ -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
View File
@@ -0,0 +1,29 @@
import { execSync } from "node:child_process";
/**
* Returns the current Git diff for the working directory. If the current
* working directory is not inside a Git repository, `isGitRepo` will be
* false and `diff` will be an empty string.
*/
export function getGitDiff(): {
isGitRepo: boolean;
diff: string;
} {
try {
// First check whether we are inside a git repository. `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: "" };
}
}
+22 -11
View File
@@ -4,8 +4,8 @@ import { fileTypeFromBuffer } from "file-type";
import fs from "fs/promises";
import path from "node:path";
// Map data‑urls → original filenames so TUI can render friendly labels.
// Populated during createInputItem.
// 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(
@@ -20,30 +20,41 @@ export async function createInputItem(
for (const filePath of images) {
/* eslint-disable no-await-in-loop */
let binary: Buffer;
let binary: Buffer | undefined;
try {
binary = await fs.readFile(filePath);
} catch (err: unknown) {
// Gracefully skip 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. For any other error
// codes re‑throw so callers are still notified of unexpected issues
// (e.g. permission errors).
// 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") {
// Skip silently – user will simply not include the missing image.
continue;
// 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 pretty label (relative path when possible)
// 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;
+1 -1
View File
@@ -1,4 +1,4 @@
export const CLI_VERSION = "0.1.2504172351"; // Must be in sync with package.json.
export const CLI_VERSION = "0.1.2504181820"; // Must be in sync with package.json.
export const ORIGIN = "codex_cli_ts";
export type TerminalChatSession = {
+32
View File
@@ -0,0 +1,32 @@
// Defines the available slash commands and their descriptions.
// Used for autocompletion in the chat input.
export interface SlashCommand {
command: string;
description: string;
}
export const SLASH_COMMANDS: Array<SlashCommand> = [
{
command: "/clear",
description: "Clear conversation history and free up context",
},
{
command: "/clearhistory",
description: "Clear command history",
},
{
command: "/compact",
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
},
{ command: "/history", description: "Open command history" },
{ command: "/help", description: "Show list of commands" },
{ command: "/model", description: "Open model selection panel" },
{ command: "/approval", description: "Open approval mode selection panel" },
{ command: "/bug", description: "Generate a prefilled GitHub bug report" },
{
command: "/diff",
description:
"Show git diff of the working directory (or applied patches if not in git)",
},
];
@@ -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. │
│ │
╰─────────────────────────────────────────────────────────────╯
"
`;
+30 -1
View File
@@ -1,7 +1,13 @@
import type { SafetyAssessment } from "../src/approvals";
import { canAutoApprove } from "../src/approvals";
import { describe, test, expect } from "vitest";
import { describe, test, expect, vi } from "vitest";
vi.mock("../src/utils/config", () => ({
loadConfig: () => ({
safeCommands: ["npm test", "sl"],
}),
}));
describe("canAutoApprove()", () => {
const env = {
@@ -89,4 +95,27 @@ describe("canAutoApprove()", () => {
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
});
test("commands in safeCommands config should be safe", async () => {
expect(check(["npm", "test"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["sl"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["npm", "test", "--watch"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
});
});
+2 -1
View File
@@ -13,7 +13,8 @@ vi.mock("../src/utils/input-utils.js", () => ({
}));
// mock external deps used inside chat input
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
// 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(" "),
@@ -11,7 +11,7 @@ vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async () => ({})),
imageFilenameByDataUrl: new Map(),
}));
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
+116
View File
@@ -0,0 +1,116 @@
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";
import { beforeEach } from "node:test";
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];
},
}));
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(),
});
await checkForUpdates();
// Spy on console.log to capture output
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(),
});
await checkForUpdates();
// Spy on console.log to capture output
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await checkForUpdates();
expect(logSpy).not.toHaveBeenCalled();
});
});
+41
View File
@@ -1,6 +1,7 @@
import type * as fsType from "fs";
import { loadConfig, saveConfig } from "../src/utils/config.js"; // parent import first
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
import { tmpdir } from "os";
import { join } from "path";
import { test, expect, beforeEach, afterEach, vi } from "vitest";
@@ -107,3 +108,43 @@ test("loads user instructions + project doc when codex.md is present", () => {
userInstr + "\n\n--- project-doc ---\n\n" + projectDoc,
);
});
test("loads and saves approvalMode correctly", () => {
// Setup config with approvalMode
memfs[testConfigPath] = JSON.stringify(
{
model: "mymodel",
approvalMode: AutoApprovalMode.AUTO_EDIT,
},
null,
2,
);
memfs[testInstructionsPath] = "test instructions";
// Load config and verify approvalMode
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
// Check approvalMode was loaded correctly
expect(loadedConfig.approvalMode).toBe(AutoApprovalMode.AUTO_EDIT);
// Modify approvalMode and save
const updatedConfig = {
...loadedConfig,
approvalMode: AutoApprovalMode.FULL_AUTO,
};
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
// Verify saved config contains updated approvalMode
expect(memfs[testConfigPath]).toContain(
`"approvalMode": "${AutoApprovalMode.FULL_AUTO}"`,
);
// Load again and verify updated value
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
expect(reloadedConfig.approvalMode).toBe(AutoApprovalMode.FULL_AUTO);
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import fs from "fs/promises";
import { createInputItem } from "../src/utils/input-utils.js";
describe("createInputItem", () => {
it("returns only text when no images provided", async () => {
const result = await createInputItem("hello", []);
expect(result).toEqual({
role: "user",
type: "message",
content: [{ type: "input_text", text: "hello" }],
});
});
it("includes image content for existing file", async () => {
const fakeBuffer = Buffer.from("fake image content");
const readSpy = vi
.spyOn(fs, "readFile")
.mockResolvedValue(fakeBuffer as any);
const result = await createInputItem("hello", ["dummy-path"]);
const expectedUrl = `data:application/octet-stream;base64,${fakeBuffer.toString(
"base64",
)}`;
expect(result.role).toBe("user");
expect(result.type).toBe("message");
expect(result.content.length).toBe(2);
const [textItem, imageItem] = result.content;
expect(textItem).toEqual({ type: "input_text", text: "hello" });
expect(imageItem).toEqual({
type: "input_image",
detail: "auto",
image_url: expectedUrl,
});
readSpy.mockRestore();
});
it("falls back to missing image text for non-existent file", async () => {
const filePath = "tests/__fixtures__/does-not-exist.png";
const result = await createInputItem("hello", [filePath]);
expect(result.content.length).toBe(2);
const fallbackItem = result.content[1];
expect(fallbackItem).toEqual({
type: "input_text",
text: "[missing image: does-not-exist.png]",
});
});
});
@@ -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();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "vitest";
import { SLASH_COMMANDS, type SlashCommand } from "../src/utils/slash-commands";
test("SLASH_COMMANDS includes expected commands", () => {
const commands = SLASH_COMMANDS.map((c: SlashCommand) => c.command);
expect(commands).toContain("/clear");
expect(commands).toContain("/compact");
expect(commands).toContain("/history");
expect(commands).toContain("/help");
expect(commands).toContain("/model");
expect(commands).toContain("/approval");
expect(commands).toContain("/clearhistory");
expect(commands).toContain("/diff");
});
test("filters slash commands by prefix", () => {
const prefix = "/c";
const filtered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(prefix),
);
const names = filtered.map((c: SlashCommand) => c.command);
expect(names).toEqual(
expect.arrayContaining(["/clear", "/clearhistory", "/compact"]),
);
expect(names).not.toEqual(
expect.arrayContaining(["/history", "/help", "/model", "/approval"]),
);
const emptyPrefixFiltered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(""),
);
const emptyPrefixNames = emptyPrefixFiltered.map(
(c: SlashCommand) => c.command,
);
expect(emptyPrefixNames).toEqual(
expect.arrayContaining(SLASH_COMMANDS.map((c: SlashCommand) => c.command)),
);
expect(emptyPrefixNames).toHaveLength(SLASH_COMMANDS.length);
});
@@ -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();
+2 -2
View File
@@ -11,7 +11,7 @@
],
"types": ["node"],
"baseUrl": "./",
"resolveJsonModule": false, // ESM doesn't yet support JSON modules.
"resolveJsonModule": true, // ESM doesn't yet support JSON modules.
"jsx": "react",
"declaration": true,
"newLine": "lf",
@@ -30,5 +30,5 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
"include": ["src", "tests", "bin"]
}
+1
View File
@@ -0,0 +1 @@
{"numTotalTestSuites":3,"numPassedTestSuites":0,"numFailedTestSuites":3,"numPendingTestSuites":0,"numTotalTests":0,"numPassedTests":0,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1745144446038,"success":false,"testResults":[{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/attachment-preview.test.tsx"},{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/backspace-delete-image.test.tsx"},{"assertionResults":[],"startTime":1745144446038,"endTime":1745144446038,"status":"failed","message":"Transform failed with 1 error:\n/Users/easong/code/codex-public/codex/codex-cli/src/components/chat/terminal-chat-input.tsx:283:6: ERROR: Expected \")\" but found \"if\"","name":"/Users/easong/code/codex-public/codex/codex-cli/tests/terminal-chat-input-compact.test.tsx"}]}
-359
View File
@@ -1,359 +0,0 @@
{
"name": "codex",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"git-cliff": "^2.8.0",
"prettier": "^3.5.3"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/git-cliff": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff/-/git-cliff-2.8.0.tgz",
"integrity": "sha512-iKF5QTXAb9+iVvmu5HpnMPWYw7fs74xkpAaRbSf29+dZaMTTNRIUST/y+Ir2S1bDUWWJNjXlwT9ZT62JuYLQnA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"execa": "^8.0.1"
},
"bin": {
"git-cliff": "lib/cli/cli.js"
},
"engines": {
"node": ">=18.19 || >=20.6 || >=21"
},
"optionalDependencies": {
"git-cliff-darwin-arm64": "2.8.0",
"git-cliff-darwin-x64": "2.8.0",
"git-cliff-linux-arm64": "2.8.0",
"git-cliff-linux-x64": "2.8.0",
"git-cliff-windows-arm64": "2.8.0",
"git-cliff-windows-x64": "2.8.0"
}
},
"node_modules/git-cliff-darwin-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-darwin-arm64/-/git-cliff-darwin-arm64-2.8.0.tgz",
"integrity": "sha512-rurUV2d1Z2n+c2+wUrO0gZaFb3c1G+ej0bPfKTPfde/CblxiysMkh+4dz23NrVbc8IlS5rSYv/JFGVaVSBNJRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/git-cliff-darwin-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-darwin-x64/-/git-cliff-darwin-x64-2.8.0.tgz",
"integrity": "sha512-Wtj+FGWZBWmeYUAGlkfz7QPz4+VVxxDPMhQ/7iwKVA3iryIX0slGfzYpqMurEFnTAMr0r+4IU3Q4O/ib7iUscg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/git-cliff-linux-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-linux-arm64/-/git-cliff-linux-arm64-2.8.0.tgz",
"integrity": "sha512-k4RdfMdORXyefznWlQb+7wDgo7XgQF9qg8hJC34bwyJK2sODirrGau3uTx1/9Fi37g+pAOM7wM+LYppHCTZ2bQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/git-cliff-linux-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-linux-x64/-/git-cliff-linux-x64-2.8.0.tgz",
"integrity": "sha512-FcWX4GHgodYrQlZR03fzooanStgR03JNWvyaMQB1asplQ18nlziK2UyA+PESCIxOQmeLXauqoCApfzmdtp5myg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/git-cliff-windows-arm64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-windows-arm64/-/git-cliff-windows-arm64-2.8.0.tgz",
"integrity": "sha512-GJSrqmBVTbMtBJI3/YCDxLviZZDgYgnKqYgquBk2u2AELAnnuWFnVFQ7ZEBUqgFF2UJu9EdV2Nv6MV8d/wnP0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/git-cliff-windows-x64": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/git-cliff-windows-x64/-/git-cliff-windows-x64-2.8.0.tgz",
"integrity": "sha512-8jl0YMXPYjUmVygUEeQ4wf1zte3Rv8LPq1sIklUKl80XE4g2Gm/8EIWbKpUPLQH6IncRwepY6VuMgpVpPXbwNw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
}
}
}
+35 -3
View File
@@ -1,13 +1,45 @@
{
"name": "codex-monorepo",
"private": true,
"description": "Tools for repo-wide maintenance.",
"scripts": {
"release": "cd codex-cli && npm run release",
"release": "pnpm --filter @openai/codex run release",
"format": "prettier --check *.json *.md .github/workflows/*.yml",
"format:fix": "prettier --write *.json *.md .github/workflows/*.yml",
"changelog": "git-cliff --config cliff.toml --output CHANGELOG.ignore.md $LAST_RELEASE_TAG..HEAD"
"build": "pnpm --filter @openai/codex run build",
"test": "pnpm --filter @openai/codex run test",
"lint": "pnpm --filter @openai/codex run lint",
"typecheck": "pnpm --filter @openai/codex run typecheck",
"changelog": "git-cliff --config cliff.toml --output CHANGELOG.ignore.md $LAST_RELEASE_TAG..HEAD",
"prepare": "husky",
"husky:add": "husky add"
},
"devDependencies": {
"git-cliff": "^2.8.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3"
}
},
"resolutions": {
"braces": "^3.0.3",
"micromatch": "^4.0.8",
"semver": "^7.7.1"
},
"overrides": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=22",
"pnpm": ">=9.0.0"
},
"lint-staged": {
"*.json": "prettier --write",
"*.md": "prettier --write",
".github/workflows/*.yml": "prettier --write",
"**/*.{js,ts,tsx}": [
"cd codex-cli && pnpm run lint",
"cd codex-cli && pnpm run typecheck"
]
},
"packageManager": "pnpm@10.8.1"
}
+4936
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- codex-cli
- docs
- packages/*
ignoredBuiltDependencies:
- esbuild