This commit is contained in:
easong-openai
2025-08-13 14:27:03 -07:00
101 changed files with 40340 additions and 1778 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser
+6 -8
View File
@@ -8,8 +8,8 @@
"@actions/github": "^6.0.1",
},
"devDependencies": {
"@types/bun": "^1.2.19",
"@types/node": "^24.1.0",
"@types/bun": "^1.2.20",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
},
@@ -48,15 +48,15 @@
"@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
"@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -72,7 +72,7 @@
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="],
@@ -82,8 +82,6 @@
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"bun-types/@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
+2 -2
View File
@@ -13,8 +13,8 @@
"@actions/github": "^6.0.1"
},
"devDependencies": {
"@types/bun": "^1.2.19",
"@types/node": "^24.1.0",
"@types/bun": "^1.2.20",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
}
+1 -1
View File
@@ -1,3 +1,3 @@
model = "o3"
model = "gpt-5"
# Consider setting [mcp_servers] here!
+17 -5
View File
@@ -34,7 +34,7 @@ jobs:
# CI to validate on different os/targets
lint_build_test:
name: ${{ matrix.runner }} - ${{ matrix.target }}
name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
@@ -49,18 +49,31 @@ jobs:
include:
- runner: macos-14
target: aarch64-apple-darwin
profile: dev
- runner: macos-14
target: x86_64-apple-darwin
profile: dev
- runner: macos-14
target: aarch64-apple-darwin
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
- runner: windows-latest
target: x86_64-pc-windows-msvc
profile: dev
steps:
- uses: actions/checkout@v4
@@ -77,7 +90,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
@@ -86,7 +99,6 @@ jobs:
- name: cargo clippy
id: clippy
continue-on-error: true
run: cargo clippy --target ${{ matrix.target }} --all-features --tests -- -D warnings
# Running `cargo build` from the workspace root builds the workspace using
@@ -98,12 +110,12 @@ jobs:
id: build
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
continue-on-error: true
run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build'
run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build --profile ${{ matrix.profile }}'
- name: cargo test
id: test
continue-on-error: true
run: cargo test --all-features --target ${{ matrix.target }}
run: cargo test --all-features --target ${{ matrix.target }} --profile ${{ matrix.profile }}
env:
RUST_BACKTRACE: 1
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
+2
View File
@@ -2,6 +2,8 @@
In the codex-rs folder where the rust code lives:
- Crate names are prefixed with `codex-`. For examole, the `core` folder's crate is named `codex-core`
- When using format! and you can inline variables into {}, always do that.
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
+38 -6
View File
@@ -18,7 +18,9 @@
- [Quickstart](#quickstart)
- [Installing and running Codex CLI](#installing-and-running-codex-cli)
- [Using Codex with your ChatGPT plan](#using-codex-with-your-chatgpt-plan)
- [Connecting through VPS or remote](#connecting-through-vps-or-remote)
- [Connecting on a "Headless" Machine](#connecting-on-a-headless-machine)
- [Authenticate locally and copy your credentials to the "headless" machine](#authenticate-locally-and-copy-your-credentials-to-the-headless-machine)
- [Connecting through VPS or remote](#connecting-through-vps-or-remote)
- [Usage-based billing alternative: Use an OpenAI API key](#usage-based-billing-alternative-use-an-openai-api-key)
- [Choosing Codex's level of autonomy](#choosing-codexs-level-of-autonomy)
- [**1. Read/write**](#1-readwrite)
@@ -99,17 +101,47 @@ Each archive contains a single entry with the platform baked into the name (e.g.
<img src="./.github/codex-cli-login.png" alt="Codex CLI login" width="50%" />
</p>
After you run `codex` select Sign in with ChatGPT. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
Run `codex` and select **Sign in with ChatGPT**. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
> Important: If you've used the Codex CLI before, you'll need to follow these steps to migrate from usage-based billing with your API key:
> Important: If you've used the Codex CLI before, follow these steps to migrate from usage-based billing with your API key:
>
> 1. Update the CLI with `codex update` and ensure `codex --version` is greater than 0.13
> 2. Ensure that there is no `OPENAI_API_KEY` environment variable set. (Check that `env | grep 'OPENAI_API_KEY'` returns empty)
> 1. Update the CLI and ensure `codex --version` is `0.20.0` or later
> 2. Delete `~/.codex/auth.json` (this should be `C:\Users\USERNAME\.codex\auth.json` on Windows)
> 3. Run `codex login` again
If you encounter problems with the login flow, please comment on [this issue](https://github.com/openai/codex/issues/1243).
### Connecting through VPS or remote
### Connecting on a "Headless" Machine
Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds:
#### Authenticate locally and copy your credentials to the "headless" machine
The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\.codex`).
Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do:
```shell
# substitute MY_CONTAINER with the name or id of your Docker container:
CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME)
docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex"
docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json"
```
whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol):
```shell
ssh user@remote 'mkdir -p ~/.codex'
scp ~/.codex/auth.json user@remote:~/.codex/auth.json
```
or try this one-liner:
```shell
ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json
```
#### Connecting through VPS or remote
If you run Codex on a remote machine (VPS/server) without a local browser, the login helper starts a server on `localhost:1455` on the remote host. To complete login in your local browser, forward that port to your machine before starting the login flow:
+36 -3
View File
@@ -43,7 +43,7 @@ switch (platform) {
targetTriple = "x86_64-pc-windows-msvc.exe";
break;
case "arm64":
// We do not build this today, fall through...
// We do not build this today, fall through...
default:
break;
}
@@ -65,9 +65,43 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
// receives a fatal signal, both processes exit in a predictable manner.
const { spawn } = await import("child_process");
async function tryImport(moduleName) {
try {
// eslint-disable-next-line node/no-unsupported-features/es-syntax
return await import(moduleName);
} catch (err) {
return null;
}
}
async function resolveRgDir() {
const ripgrep = await tryImport("@vscode/ripgrep");
if (!ripgrep?.rgPath) {
return null;
}
return path.dirname(ripgrep.rgPath);
}
function getUpdatedPath(newDirs) {
const pathSep = process.platform === "win32" ? ";" : ":";
const existingPath = process.env.PATH || "";
const updatedPath = [
...newDirs,
...existingPath.split(pathSep).filter(Boolean),
].join(pathSep);
return updatedPath;
}
const additionalDirs = [];
const rgDir = await resolveRgDir();
if (rgDir) {
additionalDirs.push(rgDir);
}
const updatedPath = getUpdatedPath(additionalDirs);
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
env: { ...process.env, CODEX_MANAGED_BY_NPM: "1" },
env: { ...process.env, PATH: updatedPath, CODEX_MANAGED_BY_NPM: "1" },
});
child.on("error", (err) => {
@@ -120,4 +154,3 @@ if (childResult.type === "signal") {
} else {
process.exit(childResult.exitCode);
}
+119
View File
@@ -0,0 +1,119 @@
{
"name": "@openai/codex",
"version": "0.0.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openai/codex",
"version": "0.0.0-dev",
"license": "Apache-2.0",
"dependencies": {
"@vscode/ripgrep": "^1.15.14"
},
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@vscode/ripgrep": {
"version": "1.15.14",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz",
"integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"https-proxy-agent": "^7.0.2",
"proxy-from-env": "^1.1.0",
"yauzl": "^2.9.2"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
}
}
}
+6
View File
@@ -16,5 +16,11 @@
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git"
},
"dependencies": {
"@vscode/ripgrep": "^1.15.14"
},
"devDependencies": {
"prettier": "^3.3.3"
}
}
+30 -14
View File
@@ -538,9 +538,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.5.41"
version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -548,9 +548,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.41"
version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
dependencies = [
"anstream",
"anstyle",
@@ -561,9 +561,9 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.5.55"
version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5abde44486daf70c5be8b8f8f1b66c49f86236edf6fa2abadb4d961c4c6229a"
checksum = "67e4efcbb5da11a92e8a609233aa1e8a7d91e38de0be865f016d14700d45a7fd"
dependencies = [
"clap",
]
@@ -679,7 +679,7 @@ dependencies = [
"clap",
"codex-core",
"serde",
"toml 0.9.4",
"toml 0.9.5",
]
[[package]]
@@ -707,9 +707,11 @@ dependencies = [
"mcp-types",
"mime_guess",
"openssl-sys",
"os_info",
"predicates",
"pretty_assertions",
"rand 0.9.2",
"regex-lite",
"reqwest",
"seccompiler",
"serde",
@@ -725,7 +727,7 @@ dependencies = [
"tokio",
"tokio-test",
"tokio-util",
"toml 0.9.4",
"toml 0.9.5",
"toml_edit 0.23.3",
"tracing",
"tree-sitter",
@@ -865,7 +867,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-test",
"toml 0.9.4",
"toml 0.9.5",
"tracing",
"tracing-subscriber",
"uuid",
@@ -884,7 +886,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"toml 0.9.4",
"toml 0.9.5",
"tracing",
"wiremock",
]
@@ -910,7 +912,9 @@ dependencies = [
"image",
"insta",
"lazy_static",
"libc",
"mcp-types",
"once_cell",
"path-clean",
"pretty_assertions",
"rand 0.8.5",
@@ -3154,6 +3158,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "os_info"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
dependencies = [
"log",
"plist",
"serde",
"windows-sys 0.52.0",
]
[[package]]
name = "overload"
version = "0.1.1"
@@ -4918,9 +4934,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.15"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",
@@ -4943,9 +4959,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap 2.10.0",
"serde",
+27 -1
View File
@@ -82,8 +82,9 @@ pub struct ApplyPatchArgs {
}
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
match argv {
[cmd, body] if cmd == "apply_patch" => match parse_patch(body) {
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
@@ -722,6 +723,31 @@ mod tests {
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_heredoc() {
let args = strs_to_strings(&[
+2 -1
View File
@@ -1,4 +1,5 @@
use codex_core::config::Config;
use codex_core::user_agent::get_codex_user_agent;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -30,7 +31,7 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.header("User-Agent", "codex-cli")
.header("User-Agent", get_codex_user_agent(None))
.send()
.await
.context("Failed to send request")?;
+5 -1
View File
@@ -27,7 +27,11 @@ use crate::proto::ProtoCli;
author,
version,
// If a sub‑command is given, ignore requirements of the default args.
subcommand_negates_reqs = true
subcommand_negates_reqs = true,
// The executable is sometimes invoked via a platform‑specific name like
// `codex-x86_64-unknown-linux-musl`, but the help output should always use
// the generic `codex` command name that users run.
bin_name = "codex"
)]
struct MultitoolCli {
#[clap(flatten)]
+31 -16
View File
@@ -1,15 +1,14 @@
use std::io::IsTerminal;
use std::sync::Arc;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Submission;
use codex_core::util::notify_on_sigint;
use codex_login::CodexAuth;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -36,22 +35,38 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
let auth = CodexAuth::from_codex_home(&config.codex_home)?;
let ctrl_c = notify_on_sigint();
let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
let codex = Arc::new(codex);
// Use conversation_manager API to start a conversation
let conversation_manager = ConversationManager::default();
let NewConversation {
conversation_id: _,
conversation,
session_configured,
} = conversation_manager.new_conversation(config).await?;
// Simulate streaming the session_configured event.
let synthetic_event = Event {
// Fake id value.
id: "".to_string(),
msg: EventMsg::SessionConfigured(session_configured),
};
let session_configured_event = match serde_json::to_string(&synthetic_event) {
Ok(s) => s,
Err(e) => {
error!("Failed to serialize session_configured: {e}");
return Err(anyhow::Error::from(e));
}
};
println!("{session_configured_event}");
// Task that reads JSON lines from stdin and forwards to Submission Queue
let sq_fut = {
let codex = codex.clone();
let ctrl_c = ctrl_c.clone();
let conversation = conversation.clone();
async move {
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
loop {
let result = tokio::select! {
_ = ctrl_c.notified() => {
info!("Interrupted, exiting");
_ = tokio::signal::ctrl_c() => {
break
},
res = lines.next_line() => res,
@@ -65,7 +80,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
}
match serde_json::from_str::<Submission>(line) {
Ok(sub) => {
if let Err(e) = codex.submit_with_id(sub).await {
if let Err(e) = conversation.submit_with_id(sub).await {
error!("{e:#}");
break;
}
@@ -88,8 +103,8 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let eq_fut = async move {
loop {
let event = tokio::select! {
_ = ctrl_c.notified() => break,
event = codex.next_event() => event,
_ = tokio::signal::ctrl_c() => break,
event = conversation.next_event() => event,
};
match event {
Ok(event) => {
+2 -2
View File
@@ -17,7 +17,7 @@ Both the `--config` flag and the `config.toml` file support the following option
The model that Codex should use.
```toml
model = "o3" # overrides the default of "codex-mini-latest"
model = "o3" # overrides the default of "gpt-5"
```
## model_providers
@@ -213,7 +213,7 @@ Users can specify config values at multiple levels. Order of precedence is as fo
1. custom command-line argument, e.g., `--model o3`
2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
3. as an entry in `config.toml`, e.g., `model = "o3"`
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `codex-mini-latest`)
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5`)
## model_reasoning_effort
+5 -3
View File
@@ -27,11 +27,12 @@ futures = "0.3"
libc = "0.2.174"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
os_info = "3.12.0"
rand = "0.9"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_bytes = "0.11"
serde_json = "1"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
@@ -46,8 +47,8 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
tokio-util = "0.7.14"
toml = "0.9.4"
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
@@ -75,6 +76,7 @@ core_test_support = { path = "tests/common" }
maplit = "1.0.2"
predicates = "3"
pretty_assertions = "1.4.1"
regex-lite = "0.1.6"
tempfile = "3"
tokio-test = "0.4"
walkdir = "2.5.0"
+41 -25
View File
@@ -1,6 +1,7 @@
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
Your capabilities:
- Receive user prompts and other context provided by the harness, such as files in the workspace.
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
@@ -20,11 +21,13 @@ Your default personality and tone is concise, direct, and friendly. You communic
Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:
- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
- **Keep it concise**: be no more than 1-2 sentences (8–12 words for quick updates).
- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
**Examples:**
- “I’ve explored the repo; now checking the API route definitions.”
- “Next, I’ll patch the config and update the related tests.”
- “I’m about to scaffold the CLI commands and helper functions.”
@@ -34,15 +37,18 @@ Before making tool calls, send a brief preamble to the user explaining what you
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
- “Spotted a clever caching util; now hunting where it gets used.”
**Avoiding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
- Jumping straight into tool calls without explaining what’s about to happen.
- Writing overly long or speculative preambles — focus on immediate, tangible next steps.
## Planning
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. Note that plans are not for padding out simple work with filler steps or stating the obvious. Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.
Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
Use a plan when:
- The task is non-trivial and will require multiple actions over a long time horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
@@ -51,14 +57,6 @@ Use a plan when:
- The user has asked you to use the plan tool (aka "TODOs")
- You generate additional steps while working, and plan to do them before yielding to the user
Skip a plan when:
- The task is simple and direct.
- Breaking it down would only produce literal or trivial steps.
Planning steps are called "steps" in the tool, but really they're more like tasks or TODOs. As such they should be very concise descriptions of non-obvious work that an engineer might do like "Write the API spec", then "Update the backend", then "Implement the frontend". On the other hand, it's obvious that you'll usually have to "Explore the codebase" or "Implement the changes", so those are not worth tracking in your plan.
It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
### Examples
**High-quality plans**
@@ -115,10 +113,11 @@ If you need to write a plan, only write high quality plans, not low quality ones
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
You MUST adhere to the following criteria when solving queries:
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
- Analyzing code for vulnerabilities is allowed.
- Showing user code and tool call details is allowed.
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
@@ -148,21 +147,25 @@ For all of testing, running, building, and formatting, do not attempt to fix unr
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
Filesystem sandboxing prevents you from editing files without user approval. The options are:
- *read-only*: You can only read files.
- *workspace-write*: You can read files. You can write to files in your workspace folder, but not outside it.
- *danger-full-access*: No filesystem sandboxing.
- **read-only**: You can only read files.
- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
- **danger-full-access**: No filesystem sandboxing.
Network sandboxing prevents you from accessing network without approval. Options are
- *ON*
- *OFF*
- **restricted**
- **enabled**
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
- *untrusted*: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- *on-failure*: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- *on-request*: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- *never*: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
@@ -207,6 +210,7 @@ Brevity is very important as a default. You should be very concise (i.e. no more
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
**Section Headers**
- Use only when they improve clarity — they are not mandatory for every answer.
- Choose descriptive names that fit the content
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
@@ -214,6 +218,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
**Bullets**
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
@@ -222,11 +227,13 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use consistent keyword phrasing and formatting across sections.
**Monospace**
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).
**Structure**
- Place related bullets together; don’t mix unrelated concepts in the same section.
- Order sections from general → specific → supporting info.
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
@@ -235,6 +242,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Simple results → minimal headers, possibly just a short list or paragraph.
**Tone**
- Keep the voice collaborative and natural, like a coding partner handing off work.
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
@@ -242,6 +250,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use parallel structure in lists for consistency.
**Don’t**
- Don’t use literal words “bold” or “monospace” in the content.
- Don’t nest bullets or create deep hierarchies.
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
@@ -252,7 +261,14 @@ Generally, ensure your final answers adapt their shape and depth to the request.
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
# Tools
# Tool Guidelines
## Shell commands
When using the shell, you must adhere to the following guidelines:
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
## `apply_patch`
+3
View File
@@ -588,6 +588,9 @@ where
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
continue;
}
}
}
}
+15 -5
View File
@@ -38,6 +38,7 @@ use crate::model_provider_info::WireApi;
use crate::models::ResponseItem;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::TokenUsage;
use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use std::sync::Arc;
@@ -169,6 +170,7 @@ impl ModelClient {
store,
stream: true,
include,
prompt_cache_key: Some(self.session_id.to_string()),
};
let mut attempt = 0;
@@ -207,6 +209,7 @@ impl ModelClient {
.as_deref()
.unwrap_or("codex_cli_rs");
req_builder = req_builder.header("originator", originator);
req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
let res = req_builder.send().await;
if let Ok(resp) = &res {
@@ -403,6 +406,8 @@ async fn process_sse<S>(
}
};
trace!("SSE event: {}", sse.data);
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
@@ -411,7 +416,6 @@ async fn process_sse<S>(
}
};
trace!(?event, "SSE event");
match event.kind.as_str() {
// Individual output item finalised. Forward immediately so the
// rest of the agent can stream assistant text/functions *live*
@@ -503,12 +507,18 @@ async fn process_sse<S>(
| "response.function_call_arguments.delta"
| "response.in_progress"
| "response.output_item.added"
| "response.output_text.done"
| "response.reasoning_summary_part.added"
| "response.reasoning_summary_text.done" => {
// Currently, we ignore these events, but we handle them
| "response.output_text.done" => {
// Currently, we ignore this event, but we handle it
// separately to skip the logging message in the `other` case.
}
"response.reasoning_summary_part.added" => {
// Boundary between reasoning summary sections (e.g., titles).
let event = ResponseEvent::ReasoningSummaryPartAdded;
if tx_event.send(Ok(event)).await.is_err() {
return;
}
}
"response.reasoning_summary_text.done" => {}
other => debug!(other, "sse event"),
}
}
+3
View File
@@ -144,6 +144,7 @@ pub enum ResponseEvent {
OutputTextDelta(String),
ReasoningSummaryDelta(String),
ReasoningContentDelta(String),
ReasoningSummaryPartAdded,
}
#[derive(Debug, Serialize)]
@@ -215,6 +216,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) prompt_cache_key: Option<String>,
}
pub(crate) fn create_reasoning_param_for_request(
+45 -60
View File
@@ -20,7 +20,6 @@ use futures::prelude::*;
use mcp_types::CallToolResult;
use serde::Serialize;
use serde_json;
use tokio::sync::Notify;
use tokio::sync::oneshot;
use tokio::task::AbortHandle;
use tracing::debug;
@@ -51,6 +50,7 @@ use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::exec_env::create_env;
use crate::mcp_connection_manager::McpConnectionManager;
@@ -65,6 +65,7 @@ use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
@@ -73,6 +74,7 @@ use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentDeltaEvent;
use crate::protocol::AgentReasoningRawContentEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
use crate::protocol::BackgroundEventEvent;
@@ -121,11 +123,7 @@ pub struct CodexSpawnOk {
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(
config: Config,
auth: Option<CodexAuth>,
ctrl_c: Arc<Notify>,
) -> CodexResult<CodexSpawnOk> {
pub async fn spawn(config: Config, auth: Option<CodexAuth>) -> CodexResult<CodexSpawnOk> {
// experimental resume path (undocumented)
let resume_path = config.experimental_resume.clone();
info!("resume_path: {resume_path:?}");
@@ -153,9 +151,9 @@ impl Codex {
// Generate a unique ID for the lifetime of this Codex session.
let session_id = Uuid::new_v4();
tokio::spawn(submission_loop(
session_id, config, auth, rx_sub, tx_event, ctrl_c,
));
// This task will run until Op::Shutdown is received.
tokio::spawn(submission_loop(session_id, config, auth, rx_sub, tx_event));
let codex = Codex {
next_id: AtomicU64::new(0),
tx_sub,
@@ -207,7 +205,6 @@ impl Codex {
pub(crate) struct Session {
client: ModelClient,
pub(crate) tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
@@ -402,6 +399,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
@@ -429,8 +427,8 @@ impl Session {
// Because stdout and stderr could each be up to 100 KiB, we send
// truncated versions.
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
let stdout = stdout.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.chars().take(MAX_STREAM_OUTPUT).collect();
let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -489,7 +487,6 @@ impl Session {
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
exec_args.ctrl_c,
exec_args.sandbox_policy,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
@@ -502,8 +499,8 @@ impl Session {
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
stdout: String::new(),
stderr: get_error_message_ui(e),
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
};
&output_stderr
@@ -574,7 +571,7 @@ impl Session {
.await
}
pub fn abort(&self) {
fn abort(&self) {
info!("Aborting existing session");
let mut state = self.state.lock().unwrap();
state.pending_approvals.clear();
@@ -705,7 +702,6 @@ async fn submission_loop(
auth: Option<CodexAuth>,
rx_sub: Receiver<Submission>,
tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
) {
let mut sess: Option<Arc<Session>> = None;
// shorthand - send an event when there is no active session
@@ -720,21 +716,8 @@ async fn submission_loop(
tx_event.send(event).await.ok();
};
loop {
let interrupted = ctrl_c.notified();
let sub = tokio::select! {
res = rx_sub.recv() => match res {
Ok(sub) => sub,
Err(_) => break,
},
_ = interrupted => {
if let Some(sess) = sess.as_ref(){
sess.abort();
}
continue;
},
};
// To break out of this loop, send Op::Shutdown.
while let Ok(sub) = rx_sub.recv().await {
debug!(?sub, "Submission");
match sub.op {
Op::Interrupt => {
@@ -873,7 +856,6 @@ async fn submission_loop(
config.include_plan_tool,
),
tx_event: tx_event.clone(),
ctrl_c: Arc::clone(&ctrl_c),
user_instructions,
base_instructions,
approval_policy,
@@ -1474,6 +1456,13 @@ async fn try_run_turn(
};
sess.tx_event.send(event).await.ok();
}
ResponseEvent::ReasoningSummaryPartAdded => {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}),
};
sess.tx_event.send(event).await.ok();
}
ResponseEvent::ReasoningContentDelta(delta) => {
if sess.show_raw_agent_reasoning {
let event = Event {
@@ -1776,7 +1765,6 @@ fn parse_container_exec_arguments(
pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
pub ctrl_c: Arc<Notify>,
pub sandbox_policy: &'a SandboxPolicy,
pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
pub stdout_stream: Option<StdoutStream>,
@@ -1961,7 +1949,6 @@ async fn handle_container_exec_with_params(
ExecInvokeArgs {
params: params.clone(),
sandbox_type,
ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
@@ -1975,19 +1962,10 @@ async fn handle_container_exec_with_params(
match output_result {
Ok(output) => {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = &output;
let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
);
let content = format_exec_output(output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
@@ -2102,7 +2080,6 @@ async fn handle_sandbox_error(
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
@@ -2116,19 +2093,10 @@ async fn handle_sandbox_error(
match retry_output_result {
Ok(retry_output) => {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = &retry_output;
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
);
let content = format_exec_output(retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -2161,7 +2129,14 @@ async fn handle_sandbox_error(
}
/// Exec output is a pre-serialized JSON payload
fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = exec_output;
#[derive(Serialize)]
struct ExecMetadata {
exit_code: i32,
@@ -2177,8 +2152,18 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
let is_success = exit_code == 0;
let output = if is_success { stdout } else { stderr };
let mut formatted_output = output.text;
if let Some(truncated_after_lines) = output.truncated_after_lines {
formatted_output.push_str(&format!(
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
));
}
let payload = ExecOutput {
output,
output: &formatted_output,
metadata: ExecMetadata {
exit_code,
duration_seconds,
+30
View File
@@ -0,0 +1,30 @@
use crate::codex::Codex;
use crate::error::Result as CodexResult;
use crate::protocol::Event;
use crate::protocol::Op;
use crate::protocol::Submission;
pub struct CodexConversation {
codex: Codex,
}
/// Conduit for the bidirectional stream of messages that compose a conversation
/// in Codex.
impl CodexConversation {
pub(crate) fn new(codex: Codex) -> Self {
Self { codex }
}
pub async fn submit(&self, op: Op) -> CodexResult<String> {
self.codex.submit(op).await
}
/// Use sparingly: this is intended to be removed soon.
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
self.codex.submit_with_id(sub).await
}
pub async fn next_event(&self) -> CodexResult<Event> {
self.codex.next_event().await
}
}
-59
View File
@@ -1,59 +0,0 @@
use std::sync::Arc;
use crate::Codex;
use crate::CodexSpawnOk;
use crate::config::Config;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::util::notify_on_sigint;
use codex_login::CodexAuth;
use tokio::sync::Notify;
use uuid::Uuid;
/// Represents an active Codex conversation, including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct CodexConversation {
pub codex: Codex,
pub session_id: Uuid,
pub session_configured: Event,
pub ctrl_c: Arc<Notify>,
}
/// Spawn a new [`Codex`] and initialize the session.
///
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
/// is received as a response to the initial `ConfigureSession` submission so
/// that callers can surface the information to the UI.
pub async fn init_codex(config: Config) -> anyhow::Result<CodexConversation> {
let ctrl_c = notify_on_sigint();
let auth = CodexAuth::from_codex_home(&config.codex_home)?;
let CodexSpawnOk {
codex,
init_id,
session_id,
} = Codex::spawn(config, auth, ctrl_c.clone()).await?;
// The first event must be `SessionInitialized`. Validate and forward it to
// the caller so that they can display it in the conversation history.
let event = codex.next_event().await?;
if event.id != init_id
|| !matches!(
&event,
Event {
id: _id,
msg: EventMsg::SessionConfigured(_),
}
)
{
return Err(anyhow::anyhow!(
"expected SessionInitialized but got {event:?}"
));
}
Ok(CodexConversation {
codex,
session_id,
session_configured: event,
ctrl_c,
})
}
+96
View File
@@ -0,0 +1,96 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_login::CodexAuth;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::codex::Codex;
use crate::codex::CodexSpawnOk;
use crate::codex_conversation::CodexConversation;
use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
/// Represents a newly created Codex conversation, including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct NewConversation {
pub conversation_id: Uuid,
pub conversation: Arc<CodexConversation>,
pub session_configured: SessionConfiguredEvent,
}
/// [`ConversationManager`] is responsible for creating conversations and
/// maintaining them in memory.
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<Uuid, Arc<CodexConversation>>>>,
}
impl Default for ConversationManager {
fn default() -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl ConversationManager {
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
let auth = CodexAuth::from_codex_home(&config.codex_home)?;
self.new_conversation_with_auth(config, auth).await
}
/// Used for integration tests: should not be used by ordinary business
/// logic.
pub async fn new_conversation_with_auth(
&self,
config: Config,
auth: Option<CodexAuth>,
) -> CodexResult<NewConversation> {
let CodexSpawnOk {
codex,
init_id,
session_id: conversation_id,
} = Codex::spawn(config, auth).await?;
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
// history.
let event = codex.next_event().await?;
let session_configured = match event {
Event {
id,
msg: EventMsg::SessionConfigured(session_configured),
} if id == init_id => session_configured,
_ => {
return Err(CodexErr::SessionConfiguredNotFirstEvent);
}
};
let conversation = Arc::new(CodexConversation::new(codex));
self.conversations
.write()
.await
.insert(conversation_id, conversation.clone());
Ok(NewConversation {
conversation_id,
conversation,
session_configured,
})
}
pub async fn get_conversation(
&self,
conversation_id: Uuid,
) -> CodexResult<Arc<CodexConversation>> {
let conversations = self.conversations.read().await;
conversations
.get(&conversation_id)
.cloned()
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
}
}
+7
View File
@@ -3,6 +3,7 @@ use serde_json;
use std::io;
use thiserror::Error;
use tokio::task::JoinError;
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, CodexErr>;
@@ -44,6 +45,12 @@ pub enum CodexErr {
#[error("stream disconnected before completion: {0}")]
Stream(String),
#[error("no conversation with id: {0}")]
ConversationNotFound(Uuid),
#[error("session configured event was not the first event in the stream")]
SessionConfiguredNotFirstEvent,
/// Returned by run_command_stream when the spawned child process timed out (10s).
#[error("timeout waiting for child process to exit")]
Timeout,
+48 -20
View File
@@ -6,7 +6,6 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -15,7 +14,6 @@ use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::sync::Notify;
use crate::error::CodexErr;
use crate::error::Result;
@@ -80,7 +78,6 @@ pub struct StdoutStream {
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_type: SandboxType,
ctrl_c: Arc<Notify>,
sandbox_policy: &SandboxPolicy,
codex_linux_sandbox_exe: &Option<PathBuf>,
stdout_stream: Option<StdoutStream>,
@@ -89,7 +86,7 @@ pub async fn process_exec_tool_call(
let raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr> = match sandbox_type
{
SandboxType::None => exec(params, sandbox_policy, ctrl_c, stdout_stream.clone()).await,
SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await,
SandboxType::MacosSeatbelt => {
let timeout = params.timeout_duration();
let ExecParams {
@@ -103,7 +100,7 @@ pub async fn process_exec_tool_call(
env,
)
.await?;
consume_truncated_output(child, ctrl_c, timeout, stdout_stream.clone()).await
consume_truncated_output(child, timeout, stdout_stream.clone()).await
}
SandboxType::LinuxSeccomp => {
let timeout = params.timeout_duration();
@@ -124,14 +121,14 @@ pub async fn process_exec_tool_call(
)
.await?;
consume_truncated_output(child, ctrl_c, timeout, stdout_stream).await
consume_truncated_output(child, timeout, stdout_stream).await
}
};
let duration = start.elapsed();
match raw_output_result {
Ok(raw_output) => {
let stdout = String::from_utf8_lossy(&raw_output.stdout).to_string();
let stderr = String::from_utf8_lossy(&raw_output.stderr).to_string();
let stdout = raw_output.stdout.from_utf8_lossy();
let stderr = raw_output.stderr.from_utf8_lossy();
#[cfg(target_family = "unix")]
match raw_output.exit_status.signal() {
@@ -146,7 +143,9 @@ pub async fn process_exec_tool_call(
if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) {
return Err(CodexErr::Sandbox(SandboxErr::Denied(
exit_code, stdout, stderr,
exit_code,
stdout.text,
stderr.text,
)));
}
@@ -243,25 +242,47 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool {
true
}
#[derive(Debug)]
pub struct StreamOutput<T> {
pub text: T,
pub truncated_after_lines: Option<u32>,
}
#[derive(Debug)]
pub struct RawExecToolCallOutput {
pub exit_status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdout: StreamOutput<Vec<u8>>,
pub stderr: StreamOutput<Vec<u8>>,
}
impl StreamOutput<String> {
pub fn new(text: String) -> Self {
Self {
text,
truncated_after_lines: None,
}
}
}
impl StreamOutput<Vec<u8>> {
pub fn from_utf8_lossy(&self) -> StreamOutput<String> {
StreamOutput {
text: String::from_utf8_lossy(&self.text).to_string(),
truncated_after_lines: self.truncated_after_lines,
}
}
}
#[derive(Debug)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub stdout: StreamOutput<String>,
pub stderr: StreamOutput<String>,
pub duration: Duration,
}
async fn exec(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
ctrl_c: Arc<Notify>,
stdout_stream: Option<StdoutStream>,
) -> Result<RawExecToolCallOutput> {
let timeout = params.timeout_duration();
@@ -286,14 +307,13 @@ async fn exec(
env,
)
.await?;
consume_truncated_output(child, ctrl_c, timeout, stdout_stream).await
consume_truncated_output(child, timeout, stdout_stream).await
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
pub(crate) async fn consume_truncated_output(
mut child: Child,
ctrl_c: Arc<Notify>,
timeout: Duration,
stdout_stream: Option<StdoutStream>,
) -> Result<RawExecToolCallOutput> {
@@ -327,7 +347,6 @@ pub(crate) async fn consume_truncated_output(
true,
));
let interrupted = ctrl_c.notified();
let exit_status = tokio::select! {
result = tokio::time::timeout(timeout, child.wait()) => {
match result {
@@ -341,7 +360,7 @@ pub(crate) async fn consume_truncated_output(
}
}
}
_ = interrupted => {
_ = tokio::signal::ctrl_c() => {
child.start_kill()?;
synthetic_exit_status(128 + SIGKILL_CODE)
}
@@ -363,7 +382,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
max_lines: usize,
stream: Option<StdoutStream>,
is_stderr: bool,
) -> io::Result<Vec<u8>> {
) -> io::Result<StreamOutput<Vec<u8>>> {
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
let mut tmp = [0u8; 8192];
@@ -413,7 +432,16 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
}
Ok(buf)
let truncated = remaining_lines == 0 || remaining_bytes == 0;
Ok(StreamOutput {
text: buf,
truncated_after_lines: if truncated {
Some((max_lines - remaining_lines) as u32)
} else {
None
},
})
}
#[cfg(unix)]
+7 -3
View File
@@ -11,9 +11,8 @@ mod chat_completions;
mod client;
mod client_common;
pub mod codex;
pub use codex::Codex;
pub use codex::CodexSpawnOk;
pub mod codex_wrapper;
mod codex_conversation;
pub use codex_conversation::CodexConversation;
pub mod config;
pub mod config_profile;
pub mod config_types;
@@ -28,11 +27,15 @@ mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
pub use model_provider_info::create_oss_provider_with_base_url;
mod conversation_manager;
pub use conversation_manager::ConversationManager;
pub use conversation_manager::NewConversation;
pub mod model_family;
mod models;
mod openai_model_info;
@@ -46,6 +49,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod turn_diff_tracker;
pub mod user_agent;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
+333 -1
View File
@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -81,6 +82,8 @@ pub(crate) enum JsonSchema {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// MCP schema allows "number" | "integer" for Number
#[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -296,7 +299,13 @@ pub(crate) fn mcp_tool_to_openai_tool(
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
}
let serialized_input_schema = serde_json::to_value(input_schema)?;
// Serialize to a raw JSON value so we can sanitize schemas coming from MCP
// servers. Some servers omit the top-level or nested `type` in JSON
// Schemas (e.g. using enum/anyOf), or use unsupported variants like
// `integer`. Our internal JsonSchema is a small subset and requires
// `type`, so we coerce/sanitize here for compatibility.
let mut serialized_input_schema = serde_json::to_value(input_schema)?;
sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
Ok(ResponsesApiTool {
@@ -307,6 +316,120 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
/// common keywords (properties => object, items => array, enum/const/format => string)
/// and otherwise defaults to "string".
/// - Fills required child fields (e.g. array items, object properties) with
/// permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
match value {
JsonValue::Bool(_) => {
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
*value = json!({ "type": "string" });
}
JsonValue::Array(arr) => {
for v in arr.iter_mut() {
sanitize_json_schema(v);
}
}
JsonValue::Object(map) => {
// First, recursively sanitize known nested schema holders
if let Some(props) = map.get_mut("properties") {
if let Some(props_map) = props.as_object_mut() {
for (_k, v) in props_map.iter_mut() {
sanitize_json_schema(v);
}
}
}
if let Some(items) = map.get_mut("items") {
sanitize_json_schema(items);
}
// Some schemas use oneOf/anyOf/allOf - sanitize their entries
for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
if let Some(v) = map.get_mut(combiner) {
sanitize_json_schema(v);
}
}
// Normalize/ensure type
let mut ty = map
.get("type")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// If type is an array (union), pick first supported; else leave to inference
if ty.is_none() {
if let Some(JsonValue::Array(types)) = map.get("type") {
for t in types {
if let Some(tt) = t.as_str() {
if matches!(
tt,
"object" | "array" | "string" | "number" | "integer" | "boolean"
) {
ty = Some(tt.to_string());
break;
}
}
}
}
}
// Infer type if still missing
if ty.is_none() {
if map.contains_key("properties")
|| map.contains_key("required")
|| map.contains_key("additionalProperties")
{
ty = Some("object".to_string());
} else if map.contains_key("items") || map.contains_key("prefixItems") {
ty = Some("array".to_string());
} else if map.contains_key("enum")
|| map.contains_key("const")
|| map.contains_key("format")
{
ty = Some("string".to_string());
} else if map.contains_key("minimum")
|| map.contains_key("maximum")
|| map.contains_key("exclusiveMinimum")
|| map.contains_key("exclusiveMaximum")
|| map.contains_key("multipleOf")
{
ty = Some("number".to_string());
}
}
// If we still couldn't infer, default to string
let ty = ty.unwrap_or_else(|| "string".to_string());
map.insert("type".to_string(), JsonValue::String(ty.to_string()));
// Ensure object schemas have properties map
if ty == "object" {
if !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
// If additionalProperties is an object schema, sanitize it too.
// Leave booleans as-is, since JSON Schema allows boolean here.
if let Some(ap) = map.get_mut("additionalProperties") {
let is_bool = matches!(ap, JsonValue::Bool(_));
if !is_bool {
sanitize_json_schema(ap);
}
}
}
// Ensure array schemas have items
if ty == "array" && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
_ => {}
}
}
/// Returns a list of OpenAiTools based on the provided config and MCP tools.
/// Note that the keys of mcp_tools should be fully qualified names. See
/// [`McpConnectionManager`] for more details.
@@ -351,6 +474,7 @@ pub(crate) fn get_openai_tools(
mod tests {
use crate::model_family::find_family_for_model;
use mcp_types::ToolInputSchema;
use pretty_assertions::assert_eq;
use super::*;
@@ -497,4 +621,212 @@ mod tests {
})
);
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/search".to_string(),
mcp_types::Tool {
name: "search".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"query": {
"description": "search query"
}
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Search docs".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/search"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("search query".to_string())
}
)]),
required: None,
additional_properties: None,
},
description: "Search docs".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_integer_normalized_to_number() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/paginate".to_string(),
mcp_types::Tool {
name: "paginate".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"page": { "type": "integer" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Pagination".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/paginate"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"page".to_string(),
JsonSchema::Number { description: None }
)]),
required: None,
additional_properties: None,
},
description: "Pagination".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_array_without_items_gets_default_string_items() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/tags".to_string(),
mcp_types::Tool {
name: "tags".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"tags": { "type": "array" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Tags".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/tags"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"tags".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: None
}
)]),
required: None,
additional_properties: None,
},
description: "Tags".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_anyof_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/value".to_string(),
mcp_types::Tool {
name: "value".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("AnyOf Value".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/value"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None }
)]),
required: None,
additional_properties: None,
},
description: "AnyOf Value".to_string(),
strict: false,
})
);
}
}
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -42,7 +42,9 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
plan_item_props.insert("step".to_string(), JsonSchema::String { description: None });
plan_item_props.insert(
"status".to_string(),
JsonSchema::String { description: None },
JsonSchema::String {
description: Some("One of: pending, in_progress, completed".to_string()),
},
);
let plan_items_schema = JsonSchema::Array {
@@ -63,17 +65,11 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
OpenAiTool::Function(ResponsesApiTool {
name: "update_plan".to_string(),
description: r#"Use the update_plan tool to keep the user updated on the current plan for the task.
After understanding the user's task, call the update_plan tool with an initial plan. An example of a plan:
1. Explore the codebase to find relevant files (status: in_progress)
2. Implement the feature in the XYZ component (status: pending)
3. Commit changes and make a pull request (status: pending)
Each step should be a short, 1-sentence description.
Until all the steps are finished, there should always be exactly one in_progress step in the plan.
Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`.
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step.
Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
When all steps are completed, call update_plan one last time with all steps marked as `completed`."#.to_string(),
description: r#"Updates the task plan.
Provide an optional explanation and a list of plan items, each with a step and status.
At most one step can be in_progress at a time.
"#
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
+8 -2
View File
@@ -21,6 +21,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::model_provider_info::ModelProviderInfo;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Submission Queue Entry - requests from user
@@ -234,8 +235,7 @@ impl SandboxPolicy {
}
}
/// Always returns `true` for now, as we do not yet support restricting read
/// access.
/// Always returns `true`; restricting read access is not supported.
pub fn has_full_disk_read_access(&self) -> bool {
true
}
@@ -383,6 +383,8 @@ pub enum EventMsg {
/// Agent reasoning content delta event from agent.
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
/// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
/// Ack the client's configure message.
SessionConfigured(SessionConfiguredEvent),
@@ -530,6 +532,9 @@ pub struct AgentReasoningRawContentDeltaEvent {
pub delta: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningSectionBreakEvent {}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningDeltaEvent {
pub delta: String,
@@ -579,6 +584,7 @@ pub struct ExecCommandBeginEvent {
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
pub cwd: PathBuf,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
+1 -5
View File
@@ -167,9 +167,6 @@ mod tests {
for (input, expected_cmd, expected_output) in cases {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
@@ -219,7 +216,6 @@ mod tests {
justification: None,
},
SandboxType::None,
Arc::new(Notify::new()),
&SandboxPolicy::DangerFullAccess,
&None,
None,
@@ -230,7 +226,7 @@ mod tests {
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
output.stdout, expected,
output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}
+36
View File
@@ -0,0 +1,36 @@
const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub fn get_codex_user_agent(originator: Option<&str>) -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
format!(
"{}/{build_version} ({} {}; {})",
originator.unwrap_or(DEFAULT_ORIGINATOR),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent(None);
assert!(user_agent.starts_with("codex_cli_rs/"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent(None);
let re =
Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$")
.unwrap();
assert!(re.is_match(&user_agent));
}
}
-21
View File
@@ -1,32 +1,11 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use rand::Rng;
use tokio::sync::Notify;
use tracing::debug;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
/// Make a CancellationToken that is fulfilled when SIGINT occurs.
pub fn notify_on_sigint() -> Arc<Notify> {
let notify = Arc::new(Notify::new());
tokio::spawn({
let notify = Arc::clone(&notify);
async move {
loop {
tokio::signal::ctrl_c().await.ok();
debug!("Keyboard interrupt");
notify.notify_waiters();
}
}
});
notify
}
pub(crate) fn backoff(attempt: u64) -> Duration {
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
+52 -65
View File
@@ -1,14 +1,13 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::NewConversation;
use codex_core::WireApi;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
@@ -90,14 +89,15 @@ async fn includes_session_id_and_model_headers_in_request() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let NewConversation {
conversation: codex,
conversation_id,
session_configured: _,
} = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.expect("create new conversation");
codex
.submit(Op::UserInput {
@@ -108,13 +108,6 @@ async fn includes_session_id_and_model_headers_in_request() {
.await
.unwrap();
let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await
else {
unreachable!()
};
let current_session_id = Some(session_id.to_string());
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
@@ -123,10 +116,9 @@ async fn includes_session_id_and_model_headers_in_request() {
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
assert!(current_session_id.is_some());
assert_eq!(
request_session_id.to_str().unwrap(),
current_session_id.as_ref().unwrap()
conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
assert_eq!(
@@ -164,14 +156,12 @@ async fn includes_base_instructions_override_in_request() {
config.base_instructions = Some("test instructions".to_string());
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
@@ -223,14 +213,12 @@ async fn originator_config_override_is_used() {
config.model_provider = model_provider;
config.internal_originator = Some("my_override".to_string());
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
@@ -283,11 +271,15 @@ async fn chatgpt_auth_sends_correct_request() {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } =
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let NewConversation {
conversation: codex,
conversation_id,
session_configured: _,
} = conversation_manager
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
.await
.expect("create new conversation");
codex
.submit(Op::UserInput {
@@ -298,13 +290,6 @@ async fn chatgpt_auth_sends_correct_request() {
.await
.unwrap();
let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await
else {
unreachable!()
};
let current_session_id = Some(session_id.to_string());
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
@@ -315,10 +300,9 @@ async fn chatgpt_auth_sends_correct_request() {
let request_chatgpt_account_id = request.headers.get("chatgpt-account-id").unwrap();
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert!(current_session_id.is_some());
assert_eq!(
request_session_id.to_str().unwrap(),
current_session_id.as_ref().unwrap()
conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
assert_eq!(
@@ -361,14 +345,12 @@ async fn includes_user_instructions_message_in_request() {
config.model_provider = model_provider;
config.user_instructions = Some("be nice".to_string());
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
@@ -457,8 +439,12 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(config, None, ctrl_c.clone()).await.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, None)
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
@@ -531,11 +517,12 @@ async fn env_var_overrides_loaded_auth() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } =
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
+6 -3
View File
@@ -2,6 +2,7 @@
use tempfile::TempDir;
use codex_core::CodexConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -72,7 +73,7 @@ pub fn load_sse_fixture_with_id(path: impl AsRef<std::path::Path>, id: &str) ->
}
pub async fn wait_for_event<F>(
codex: &codex_core::Codex,
codex: &CodexConversation,
predicate: F,
) -> codex_core::protocol::EventMsg
where
@@ -83,16 +84,18 @@ where
}
pub async fn wait_for_event_with_timeout<F>(
codex: &codex_core::Codex,
codex: &CodexConversation,
mut predicate: F,
wait_time: tokio::time::Duration,
) -> codex_core::protocol::EventMsg
where
F: FnMut(&codex_core::protocol::EventMsg) -> bool,
{
use tokio::time::Duration;
use tokio::time::timeout;
loop {
let ev = timeout(wait_time, codex.next_event())
// Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery)
let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event())
.await
.expect("timeout waiting for event")
.expect("stream ended unexpectedly");
+7 -10
View File
@@ -1,7 +1,6 @@
#![expect(clippy::unwrap_used)]
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
@@ -142,14 +141,12 @@ async fn summarize_context_three_requests_and_instructions() {
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("dummy")),
ctrl_c.clone(),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("dummy")))
.await
.unwrap()
.conversation;
// 1) Normal user input – should hit server once.
codex
+67 -12
View File
@@ -1,25 +1,30 @@
#![cfg(target_os = "macos")]
#![expect(clippy::expect_used)]
#![expect(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::exec::ExecParams;
use codex_core::exec::ExecToolCallOutput;
use codex_core::exec::SandboxType;
use codex_core::exec::process_exec_tool_call;
use codex_core::protocol::SandboxPolicy;
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use tempfile::TempDir;
use tokio::sync::Notify;
use codex_core::error::Result;
use codex_core::get_platform_sandbox;
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
fn skip_test() -> bool {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
return;
return true;
}
false
}
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput> {
let sandbox_type = get_platform_sandbox().expect("should be able to get sandbox type");
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
@@ -32,34 +37,84 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
justification: None,
};
let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await;
assert!(result.is_ok() == should_be_ok);
process_exec_tool_call(params, sandbox_type, &policy, &None, None).await
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn exit_code_0_succeeds() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["echo", "hello"];
run_test_cmd(tmp, cmd, true).await
let output = run_test_cmd(tmp, cmd).await.unwrap();
assert_eq!(output.stdout.text, "hello\n");
assert_eq!(output.stderr.text, "");
assert_eq!(output.stdout.truncated_after_lines, None);
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn truncates_output_lines() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["seq", "300"];
#[expect(clippy::unwrap_used)]
let output = run_test_cmd(tmp, cmd).await.unwrap();
let expected_output = (1..=256)
.map(|i| format!("{i}\n"))
.collect::<Vec<_>>()
.join("");
assert_eq!(output.stdout.text, expected_output);
assert_eq!(output.stdout.truncated_after_lines, Some(256));
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn truncates_output_bytes() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
// each line is 1000 bytes
let cmd = vec!["bash", "-lc", "seq 15 | awk '{printf \"%-1000s\\n\", $0}'"];
let output = run_test_cmd(tmp, cmd).await.unwrap();
assert_eq!(output.stdout.text.len(), 10240);
assert_eq!(output.stdout.truncated_after_lines, Some(10));
}
/// Command not found returns exit code 127, this is not considered a sandbox error
#[tokio::test]
async fn exit_command_not_found_is_ok() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"];
run_test_cmd(tmp, cmd, true).await
run_test_cmd(tmp, cmd).await.unwrap();
}
/// Writing a file fails and should be considered a sandbox error
#[tokio::test]
async fn write_file_fails_as_sandbox_error() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let path = tmp.path().join("test.txt");
let cmd = vec![
@@ -67,5 +122,5 @@ async fn write_file_fails_as_sandbox_error() {
path.to_str().expect("should be able to get path"),
];
run_test_cmd(tmp, cmd, false).await;
assert!(run_test_cmd(tmp, cmd).await.is_err());
}
+3 -9
View File
@@ -2,7 +2,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use async_channel::Receiver;
use codex_core::exec::ExecParams;
@@ -14,7 +13,6 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandOutputDeltaEvent;
use codex_core::protocol::ExecOutputStream;
use codex_core::protocol::SandboxPolicy;
use tokio::sync::Notify;
fn collect_stdout_events(rx: Receiver<Event>) -> Vec<u8> {
let mut out = Vec::new();
@@ -57,13 +55,11 @@ async fn test_exec_stdout_stream_events_echo() {
justification: None,
};
let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(
params,
SandboxType::None,
ctrl_c,
&policy,
&None,
Some(stdout_stream),
@@ -76,7 +72,7 @@ async fn test_exec_stdout_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello-world\n");
assert_eq!(result.stdout.text, "hello-world\n");
let streamed = collect_stdout_events(rx);
// We should have received at least the same contents (possibly in one chunk)
@@ -109,13 +105,11 @@ async fn test_exec_stderr_stream_events_echo() {
justification: None,
};
let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(
params,
SandboxType::None,
ctrl_c,
&policy,
&None,
Some(stdout_stream),
@@ -128,8 +122,8 @@ async fn test_exec_stderr_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "oops\n");
assert_eq!(result.stdout.text, "");
assert_eq!(result.stderr.text, "oops\n");
// Collect only stderr delta events
let mut err = Vec::new();
-209
View File
@@ -1,209 +0,0 @@
#![expect(clippy::unwrap_used, clippy::expect_used)]
//! Live integration tests that exercise the full [`Agent`] stack **against the real
//! OpenAI `/v1/responses` API**. These tests complement the lightweight mock‑based
//! unit tests by verifying that the agent can drive an end‑to‑end conversation,
//! stream incremental events, execute function‑call tool invocations and safely
//! chain multiple turns inside a single session – the exact scenarios that have
//! historically been brittle.
//!
//! The live tests are **ignored by default** so CI remains deterministic and free
//! of external dependencies. Developers can opt‑in locally with e.g.
//!
//! ```bash
//! OPENAI_API_KEY=sk‑... cargo test --test live_agent -- --ignored --nocapture
//! ```
//!
//! Make sure your key has access to the experimental *Responses* API and that
//! any billable usage is acceptable.
use std::time::Duration;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::error::CodexErr;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use core_test_support::load_default_config_for_test;
use tempfile::TempDir;
use tokio::sync::Notify;
use tokio::time::timeout;
fn api_key_available() -> bool {
std::env::var("OPENAI_API_KEY").is_ok()
}
/// Helper that spawns a fresh Agent and sends the mandatory *ConfigureSession*
/// submission. The caller receives the constructed [`Agent`] plus the unique
/// submission id used for the initialization message.
async fn spawn_codex() -> Result<Codex, CodexErr> {
assert!(
api_key_available(),
"OPENAI_API_KEY must be set for live tests"
);
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider.request_max_retries = Some(2);
config.model_provider.stream_max_retries = Some(2);
let CodexSpawnOk { codex: agent, .. } =
Codex::spawn(config, None, std::sync::Arc::new(Notify::new())).await?;
Ok(agent)
}
/// Verifies that the agent streams incremental *AgentMessage* events **before**
/// emitting `TaskComplete` and that a second task inside the same session does
/// not get tripped up by a stale `previous_response_id`.
#[ignore]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn live_streaming_and_prev_id_reset() {
if !api_key_available() {
eprintln!("skipping live_streaming_and_prev_id_reset – OPENAI_API_KEY not set");
return;
}
let codex = spawn_codex().await.unwrap();
// ---------- Task 1 ----------
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "Say the words 'stream test'".into(),
}],
})
.await
.unwrap();
let mut saw_message_before_complete = false;
loop {
let ev = timeout(Duration::from_secs(60), codex.next_event())
.await
.expect("timeout waiting for task1 events")
.expect("agent closed");
match ev.msg {
EventMsg::AgentMessage(_) => saw_message_before_complete = true,
EventMsg::TaskComplete(_) => break,
EventMsg::Error(ErrorEvent { message }) => {
panic!("agent reported error in task1: {message}")
}
_ => {
// Ignore other events.
}
}
}
assert!(
saw_message_before_complete,
"Agent did not stream any AgentMessage before TaskComplete"
);
// ---------- Task 2 (same session) ----------
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "Respond with exactly: second turn succeeded".into(),
}],
})
.await
.unwrap();
let mut got_expected = false;
loop {
let ev = timeout(Duration::from_secs(60), codex.next_event())
.await
.expect("timeout waiting for task2 events")
.expect("agent closed");
match &ev.msg {
EventMsg::AgentMessage(AgentMessageEvent { message })
if message.contains("second turn succeeded") =>
{
got_expected = true;
}
EventMsg::TaskComplete(_) => break,
EventMsg::Error(ErrorEvent { message }) => {
panic!("agent reported error in task2: {message}")
}
_ => {
// Ignore other events.
}
}
}
assert!(got_expected, "second task did not receive expected answer");
}
/// Exercises a *function‑call → shell execution* round‑trip by instructing the
/// model to run a harmless `echo` command. The test asserts that:
/// 1. the function call is executed (we see `ExecCommandBegin`/`End` events)
/// 2. the captured stdout reaches the client unchanged.
#[ignore]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn live_shell_function_call() {
if !api_key_available() {
eprintln!("skipping live_shell_function_call – OPENAI_API_KEY not set");
return;
}
let codex = spawn_codex().await.unwrap();
const MARKER: &str = "codex_live_echo_ok";
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: format!(
"Use the shell function to run the command `echo {MARKER}` and no other commands."
),
}],
})
.await
.unwrap();
let mut saw_begin = false;
let mut saw_end_with_output = false;
loop {
let ev = timeout(Duration::from_secs(60), codex.next_event())
.await
.expect("timeout waiting for function‑call events")
.expect("agent closed");
match ev.msg {
EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent {
command,
..
}) => {
assert_eq!(command, vec!["echo", MARKER]);
saw_begin = true;
}
EventMsg::ExecCommandEnd(codex_core::protocol::ExecCommandEndEvent {
stdout,
exit_code,
..
}) => {
assert_eq!(exit_code, 0, "echo returned non‑zero exit code");
assert!(stdout.contains(MARKER));
saw_end_with_output = true;
}
EventMsg::TaskComplete(_) => break,
EventMsg::Error(codex_core::protocol::ErrorEvent { message }) => {
panic!("agent error during shell test: {message}")
}
_ => {
// Ignore other events.
}
}
}
assert!(saw_begin, "ExecCommandBegin event missing");
assert!(
saw_end_with_output,
"ExecCommandEnd with expected output missing"
);
}
+134
View File
@@ -0,0 +1,134 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
/// Build minimal SSE stream with completed marker using the JSON fixture.
fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prefixes_context_and_instructions_once_and_consistently_across_requests() {
#![allow(clippy::unwrap_used)]
use pretty_assertions::assert_eq;
let server = MockServer::start().await;
let sse = sse_completed("resp");
let template = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream");
// Expect two POSTs to /v1/responses
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(template)
.expect(2)
.mount(&server)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let cwd = TempDir::new().unwrap();
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.cwd = cwd.path().to_path_buf();
config.model_provider = model_provider;
config.user_instructions = Some("be consistent and helpful".to_string());
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello 1".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello 2".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "expected two POST requests");
let expected_env_text = format!(
"<environment_context>\n\nCurrent working directory: {}\nApproval policy: on-request\nSandbox policy: read-only\nNetwork access: restricted\n\n\n</environment_context>",
cwd.path().to_string_lossy()
);
let expected_ui_text =
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
let expected_env_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_env_text } ]
});
let expected_ui_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_ui_text } ]
});
let expected_user_message_1 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 1" } ]
});
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body1["input"],
serde_json::json!([expected_env_msg, expected_ui_msg, expected_user_message_1])
);
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
let body2 = requests[1].body_json::<serde_json::Value>().unwrap();
let expected_body2 = serde_json::json!(
[
body1["input"].as_array().unwrap().as_slice(),
[expected_user_message_2].as_slice(),
]
.concat()
);
assert_eq!(body2["input"], expected_body2);
}
@@ -1,7 +1,6 @@
use std::time::Duration;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::protocol::EventMsg;
@@ -90,13 +89,12 @@ async fn continue_after_stream_error() {
config.base_instructions = Some("You are a helpful assistant".to_string());
config.model_provider = provider;
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
std::sync::Arc::new(tokio::sync::Notify::new()),
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
+7 -10
View File
@@ -3,8 +3,7 @@
use std::time::Duration;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
@@ -93,17 +92,15 @@ async fn retries_on_early_close() {
requires_openai_auth: false,
};
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c,
)
.await
.unwrap();
let conversation_manager = ConversationManager::default();
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.await
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
@@ -210,6 +210,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningSectionBreak(_) => {
if !self.show_agent_reasoning {
return CodexStatus::Running;
}
println!();
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if !self.show_raw_agent_reasoning {
return CodexStatus::Running;
@@ -255,6 +263,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id,
command,
cwd,
parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id.clone(),
+18 -24
View File
@@ -6,12 +6,11 @@ mod event_processor_with_json_output;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
pub use cli::Cli;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::{self};
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_types::SandboxMode;
@@ -185,35 +184,30 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let CodexConversation {
codex: codex_wrapper,
let conversation_manager = ConversationManager::default();
let NewConversation {
conversation_id: _,
conversation,
session_configured,
ctrl_c,
..
} = codex_wrapper::init_codex(config).await?;
let codex = Arc::new(codex_wrapper);
} = conversation_manager.new_conversation(config).await?;
info!("Codex initialized with event: {session_configured:?}");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
{
let codex = codex.clone();
let conversation = conversation.clone();
tokio::spawn(async move {
loop {
let interrupted = ctrl_c.notified();
tokio::select! {
_ = interrupted => {
// Forward an interrupt to the codex so it can abort any in‑flight task.
let _ = codex
.submit(
Op::Interrupt,
)
.await;
_ = tokio::signal::ctrl_c() => {
tracing::debug!("Keyboard interrupt");
// Immediately notify Codex to abort any in‑flight task.
conversation.submit(Op::Interrupt).await.ok();
// Exit the inner loop and return to the main input prompt. The codex
// Exit the inner loop and return to the main input prompt. The codex
// will emit a `TurnInterrupted` (Error) event which is drained later.
break;
}
res = codex.next_event() => match res {
res = conversation.next_event() => match res {
Ok(event) => {
debug!("Received event: {event:?}");
@@ -243,9 +237,9 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
.into_iter()
.map(|path| InputItem::LocalImage { path })
.collect();
let initial_images_event_id = codex.submit(Op::UserInput { items }).await?;
let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;
info!("Sent images with event ID: {initial_images_event_id}");
while let Ok(event) = codex.next_event().await {
while let Ok(event) = conversation.next_event().await {
if event.id == initial_images_event_id
&& matches!(
event.msg,
@@ -261,7 +255,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// Send the prompt.
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
let initial_prompt_task_id = codex.submit(Op::UserInput { items }).await?;
let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
// Run the loop until the task is complete.
@@ -270,7 +264,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
match shutdown {
CodexStatus::Running => continue,
CodexStatus::InitiateShutdown => {
codex.submit(Op::Shutdown).await?;
conversation.submit(Op::Shutdown).await?;
}
CodexStatus::Shutdown => {
break;
+3 -9
View File
@@ -11,9 +11,7 @@ use codex_core::exec_env::create_env;
use codex_core::protocol::SandboxPolicy;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::NamedTempFile;
use tokio::sync::Notify;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
@@ -59,11 +57,9 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
let ctrl_c = Arc::new(Notify::new());
let res = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
ctrl_c,
&sandbox_policy,
&codex_linux_sandbox_exe,
None,
@@ -72,8 +68,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
.unwrap();
if res.exit_code != 0 {
println!("stdout:\n{}", res.stdout);
println!("stderr:\n{}", res.stderr);
println!("stdout:\n{}", res.stdout.text);
println!("stderr:\n{}", res.stderr.text);
panic!("exit code: {}", res.exit_code);
}
}
@@ -150,13 +146,11 @@ async fn assert_network_blocked(cmd: &[&str]) {
};
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let ctrl_c = Arc::new(Notify::new());
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe: Option<PathBuf> = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
ctrl_c,
&sandbox_policy,
&codex_linux_sandbox_exe,
None,
@@ -164,7 +158,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
.await;
let (exit_code, stdout, stderr) = match result {
Ok(output) => (output.exit_code, output.stdout, output.stderr),
Ok(output) => (output.exit_code, output.stdout.text, output.stderr.text),
Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => {
(exit_code, stdout, stderr)
}
+5 -2
View File
@@ -34,7 +34,7 @@ pub struct CodexToolCallParam {
pub cwd: Option<String>,
/// Approval policy for shell commands generated by the model:
/// `untrusted`, `on-failure`, `never`.
/// `untrusted`, `on-failure`, `on-request`, `never`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<CodexToolCallApprovalPolicy>,
@@ -63,6 +63,7 @@ pub struct CodexToolCallParam {
pub enum CodexToolCallApprovalPolicy {
Untrusted,
OnFailure,
OnRequest,
Never,
}
@@ -71,6 +72,7 @@ impl From<CodexToolCallApprovalPolicy> for AskForApproval {
match value {
CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
}
}
@@ -244,10 +246,11 @@ mod tests {
"type": "object",
"properties": {
"approval-policy": {
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`.",
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"
+29 -23
View File
@@ -5,12 +5,13 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::InputItem;
@@ -41,15 +42,14 @@ pub async fn run_codex_tool_session(
initial_prompt: String,
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
) {
let CodexConversation {
codex,
let NewConversation {
conversation_id,
conversation,
session_configured,
session_id,
..
} = match init_codex(config).await {
} = match conversation_manager.new_conversation(config).await {
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
@@ -65,16 +65,15 @@ pub async fn run_codex_tool_session(
return;
}
};
let codex = Arc::new(codex);
// update the session map so we can retrieve the session in a reply, and then drop it, since
// we no longer need it for this function
session_map.lock().await.insert(session_id, codex.clone());
drop(session_map);
let session_configured_event = Event {
// Use a fake id value for now.
id: "".to_string(),
msg: EventMsg::SessionConfigured(session_configured.clone()),
};
outgoing
.send_event_as_notification(
&session_configured,
&session_configured_event,
Some(OutgoingNotificationMeta::new(Some(id.clone()))),
)
.await;
@@ -89,7 +88,7 @@ pub async fn run_codex_tool_session(
running_requests_id_to_codex_uuid
.lock()
.await
.insert(id.clone(), session_id);
.insert(id.clone(), conversation_id);
let submission = Submission {
id: sub_id.clone(),
op: Op::UserInput {
@@ -99,18 +98,24 @@ pub async fn run_codex_tool_session(
},
};
if let Err(e) = codex.submit_with_id(submission).await {
if let Err(e) = conversation.submit_with_id(submission).await {
tracing::error!("Failed to submit initial prompt: {e}");
// unregister the id so we don't keep it in the map
running_requests_id_to_codex_uuid.lock().await.remove(&id);
return;
}
run_codex_tool_session_inner(codex, outgoing, id, running_requests_id_to_codex_uuid).await;
run_codex_tool_session_inner(
conversation,
outgoing,
id,
running_requests_id_to_codex_uuid,
)
.await;
}
pub async fn run_codex_tool_session_reply(
codex: Arc<Codex>,
conversation: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
prompt: String,
@@ -121,7 +126,7 @@ pub async fn run_codex_tool_session_reply(
.lock()
.await
.insert(request_id.clone(), session_id);
if let Err(e) = codex
if let Err(e) = conversation
.submit(Op::UserInput {
items: vec![InputItem::Text { text: prompt }],
})
@@ -137,7 +142,7 @@ pub async fn run_codex_tool_session_reply(
}
run_codex_tool_session_inner(
codex,
conversation,
outgoing,
request_id,
running_requests_id_to_codex_uuid,
@@ -146,7 +151,7 @@ pub async fn run_codex_tool_session_reply(
}
async fn run_codex_tool_session_inner(
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
@@ -257,6 +262,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
+3 -2
View File
@@ -4,7 +4,7 @@ use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
use codex_core::Codex;
use codex_core::CodexConversation;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::EventMsg;
@@ -13,7 +13,7 @@ use mcp_types::RequestId;
use tracing::error;
pub async fn run_conversation_loop(
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
) {
@@ -95,6 +95,7 @@ pub async fn run_conversation_loop(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
+3 -3
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::CodexConversation;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use mcp_types::ElicitRequest;
@@ -51,7 +51,7 @@ pub(crate) async fn handle_exec_approval_request(
command: Vec<String>,
cwd: PathBuf,
outgoing: Arc<crate::outgoing_message::OutgoingMessageSender>,
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
request_id: RequestId,
tool_call_id: String,
event_id: String,
@@ -116,7 +116,7 @@ pub(crate) async fn handle_exec_approval_request(
async fn on_exec_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
) {
let response = receiver.await;
let value = match response {
+3 -1
View File
@@ -936,6 +936,7 @@ mod tests {
call_id: "c1".into(),
command: vec!["bash".into(), "-lc".into(), "echo hi".into()],
cwd: std::path::PathBuf::from("/work"),
parsed_cmd: vec![],
}),
};
@@ -947,7 +948,8 @@ mod tests {
"type": "exec_command_begin",
"call_id": "c1",
"command": ["bash", "-lc", "echo hi"],
"cwd": "/work"
"cwd": "/work",
"parsed_cmd": []
}
}
});
+32 -41
View File
@@ -14,7 +14,7 @@ use crate::outgoing_message::OutgoingMessageSender;
use crate::tool_handlers::create_conversation::handle_create_conversation;
use crate::tool_handlers::send_message::handle_send_message;
use codex_core::Codex;
use codex_core::ConversationManager;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::Submission;
use mcp_types::CallToolRequest;
@@ -42,7 +42,7 @@ pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
initialized: bool,
codex_linux_sandbox_exe: Option<PathBuf>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
running_session_ids: Arc<Mutex<HashSet<Uuid>>>,
}
@@ -58,14 +58,14 @@ impl MessageProcessor {
outgoing: Arc::new(outgoing),
initialized: false,
codex_linux_sandbox_exe,
session_map: Arc::new(Mutex::new(HashMap::new())),
conversation_manager: Arc::new(ConversationManager::default()),
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
running_session_ids: Arc::new(Mutex::new(HashSet::new())),
}
}
pub(crate) fn session_map(&self) -> Arc<Mutex<HashMap<Uuid, Arc<Codex>>>> {
self.session_map.clone()
pub(crate) fn get_conversation_manager(&self) -> &ConversationManager {
&self.conversation_manager
}
pub(crate) fn outgoing(&self) -> Arc<OutgoingMessageSender> {
@@ -431,9 +431,9 @@ impl MessageProcessor {
}
};
// Clone outgoing and session map to move into async task.
// Clone outgoing and server to move into async task.
let outgoing = self.outgoing.clone();
let session_map = self.session_map.clone();
let conversation_manager = self.conversation_manager.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
// Spawn an async task to handle the Codex session so that we do not
@@ -445,7 +445,7 @@ impl MessageProcessor {
initial_prompt,
config,
outgoing,
session_map,
conversation_manager,
running_requests_id_to_codex_uuid,
)
.await;
@@ -516,33 +516,27 @@ impl MessageProcessor {
}
};
// load codex from session map
let session_map_mutex = Arc::clone(&self.session_map);
// Clone outgoing and session map to move into async task.
// Clone outgoing to move into async task.
let outgoing = self.outgoing.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
let codex = {
let session_map = session_map_mutex.lock().await;
match session_map.get(&session_id).cloned() {
Some(c) => c,
None => {
tracing::warn!("Session not found for session_id: {session_id}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Session not found for session_id: {session_id}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
outgoing
.send_response(request_id, serde_json::to_value(result).unwrap_or_default())
.await;
return;
}
let codex = match self.conversation_manager.get_conversation(session_id).await {
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Session not found for session_id: {session_id}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
outgoing
.send_response(request_id, serde_json::to_value(result).unwrap_or_default())
.await;
return;
}
};
@@ -609,15 +603,12 @@ impl MessageProcessor {
};
tracing::info!("session_id: {session_id}");
// Obtain the Codex Arc while holding the session_map lock, then release.
let codex_arc = {
let sessions_guard = self.session_map.lock().await;
match sessions_guard.get(&session_id) {
Some(codex) => Arc::clone(codex),
None => {
tracing::warn!("Session not found for session_id: {session_id}");
return;
}
// Obtain the Codex conversation from the server.
let codex_arc = match self.conversation_manager.get_conversation(session_id).await {
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
return;
}
};
+3 -3
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::CodexConversation;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
@@ -47,7 +47,7 @@ pub(crate) async fn handle_patch_approval_request(
grant_root: Option<PathBuf>,
changes: HashMap<PathBuf, FileChange>,
outgoing: Arc<OutgoingMessageSender>,
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
request_id: RequestId,
tool_call_id: String,
event_id: String,
@@ -111,7 +111,7 @@ pub(crate) async fn handle_patch_approval_request(
pub(crate) async fn on_patch_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
codex: Arc<Codex>,
codex: Arc<CodexConversation>,
) {
let response = receiver.await;
let value = match response {
@@ -1,16 +1,9 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::codex_wrapper::init_codex;
use codex_core::NewConversation;
use codex_core::config::Config as CodexConfig;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::conversation_loop::run_conversation_loop;
use crate::json_to_toml::json_to_toml;
@@ -81,8 +74,16 @@ pub(crate) async fn handle_create_conversation(
}
};
// Initialize Codex session
let codex_conversation = match init_codex(cfg).await {
// Initialize Codex session via server API
let NewConversation {
conversation_id: session_id,
conversation,
session_configured,
} = match message_processor
.get_conversation_manager()
.new_conversation(cfg)
.await
{
Ok(conv) => conv,
Err(e) => {
message_processor
@@ -100,41 +101,13 @@ pub(crate) async fn handle_create_conversation(
}
};
// Expect SessionConfigured; if not, return error.
let EventMsg::SessionConfigured(SessionConfiguredEvent { model, .. }) =
&codex_conversation.session_configured.msg
else {
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationCreate(
ConversationCreateResult::Error {
message: "Expected SessionConfigured event".to_string(),
},
)),
Some(true),
)
.await;
return;
};
let effective_model = session_configured.model.clone();
let effective_model = model.clone();
let session_id = codex_conversation.session_id;
let codex_arc = Arc::new(codex_conversation.codex);
// Store session for future calls
insert_session(
session_id,
codex_arc.clone(),
message_processor.session_map(),
)
.await;
// Run the conversation loop in the background so this request can return immediately.
let outgoing = message_processor.outgoing();
let spawn_id = id.clone();
tokio::spawn(async move {
run_conversation_loop(codex_arc.clone(), outgoing, spawn_id).await;
run_conversation_loop(conversation.clone(), outgoing, spawn_id).await;
});
// Reply with the new conversation id and effective model
@@ -151,12 +124,3 @@ pub(crate) async fn handle_create_conversation(
)
.await;
}
async fn insert_session(
session_id: Uuid,
codex: Arc<Codex>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
) {
let mut guard = session_map.lock().await;
guard.insert(session_id, codex);
}
@@ -1,12 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::protocol::Op;
use codex_core::protocol::Submission;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::mcp_protocol::ConversationSendMessageArgs;
use crate::mcp_protocol::ConversationSendMessageResult;
@@ -41,7 +35,11 @@ pub(crate) async fn handle_send_message(
}
let session_id = conversation_id.0;
let Some(codex) = get_session(session_id, message_processor.session_map()).await else {
let Ok(codex) = message_processor
.get_conversation_manager()
.get_conversation(session_id)
.await
else {
message_processor
.send_response_with_optional_error(
id,
@@ -114,11 +112,3 @@ pub(crate) async fn handle_send_message(
)
.await;
}
pub(crate) async fn get_session(
session_id: Uuid,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
) -> Option<Arc<Codex>> {
let guard = session_map.lock().await;
guard.get(&session_id).cloned()
}
+1 -1
View File
@@ -24,7 +24,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
toml = "0.9.2"
toml = "0.9.5"
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
+6
View File
@@ -14,6 +14,8 @@ path = "src/lib.rs"
[features]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
[lints]
workspace = true
@@ -39,6 +41,7 @@ crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
lazy_static = "1"
once_cell = "1"
mcp-types = { path = "../mcp-types" }
path-clean = "1.0.1"
ratatui = { version = "0.29.0", features = [
@@ -72,6 +75,9 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
+69 -25
View File
@@ -9,9 +9,9 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::should_show_login_screen;
use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
@@ -32,7 +32,7 @@ use std::thread;
use std::time::Duration;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
@@ -49,6 +49,7 @@ enum AppState<'a> {
}
pub(crate) struct App<'a> {
server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
app_event_rx: Receiver<AppEvent>,
app_state: AppState<'a>,
@@ -64,6 +65,9 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec<Line<'static>>,
enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -83,6 +87,8 @@ impl App<'_> {
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::default());
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false));
@@ -111,10 +117,8 @@ impl App<'_> {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when
// pasting, e.g. [iTerm2][]. But [tui-textarea
// expects \n][tui-textarea]. This seems like a bug
// in tui-textarea IMO, but work around it for now.
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
@@ -153,6 +157,7 @@ impl App<'_> {
} else {
let chat_widget = ChatWidget::new(
config.clone(),
conversation_manager.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
@@ -165,6 +170,7 @@ impl App<'_> {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
Self {
server: conversation_manager,
app_event_tx,
pending_history_lines: Vec::new(),
app_event_rx,
@@ -173,6 +179,7 @@ impl App<'_> {
file_search,
pending_redraw,
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
}
}
@@ -189,7 +196,7 @@ impl App<'_> {
// redraw is already pending so we can return early.
if self
.pending_redraw
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
@@ -200,7 +207,7 @@ impl App<'_> {
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
pending_redraw.store(false, Ordering::SeqCst);
pending_redraw.store(false, Ordering::Release);
});
}
@@ -221,6 +228,30 @@ impl App<'_> {
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(50));
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
}
}
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {
@@ -236,29 +267,17 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::ExitRequest);
}
},
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press,
..
} => match &mut self.app_state {
AppState::Chat { widget } => {
if !widget.on_esc() {
self.dispatch_key_event(key_event);
}
}
AppState::Onboarding { .. } => {
self.dispatch_key_event(key_event);
}
},
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_ctrl_z();
#[cfg(unix)]
{
self.suspend(terminal)?;
}
// No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('d'),
@@ -289,7 +308,7 @@ impl App<'_> {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events for now.
// Ignore Release key events.
}
};
}
@@ -315,6 +334,7 @@ impl App<'_> {
// User accepted – switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.server.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
@@ -366,6 +386,11 @@ impl App<'_> {
widget.add_diff_output(text);
}
}
SlashCommand::Mention => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
@@ -378,6 +403,7 @@ impl App<'_> {
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
@@ -430,6 +456,7 @@ impl App<'_> {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
self.server.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
@@ -454,6 +481,23 @@ impl App<'_> {
Ok(())
}
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
// current process group (pid 0) to trigger standard job-control
// suspension semantics. This FFI does not involve any raw pointers,
// is not called from a signal handler, and uses a constant signal.
// Errors from kill are acceptable (e.g., if already stopped) — the
// subsequent re-init path will still leave the terminal in a good state.
// We considered `nix`, but didn't think it was worth pulling in for this one call.
unsafe { libc::kill(0, libc::SIGTSTP) };
*terminal = tui::init(&self.config)?;
terminal.clear()?;
self.app_event_tx.send(AppEvent::RequestRedraw);
Ok(())
}
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
+5
View File
@@ -7,6 +7,7 @@ use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
@@ -50,6 +51,10 @@ pub(crate) enum AppEvent {
InsertHistory(Vec<Line<'static>>),
StartCommitAnimation,
StopCommitAnimation,
CommitTick,
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
+6
View File
@@ -1,6 +1,7 @@
use std::sync::mpsc::Sender;
use crate::app_event::AppEvent;
use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
@@ -15,6 +16,11 @@ impl AppEventSender {
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
// Record inbound events for high-fidelity session replay.
// Avoid double-logging Ops; those are logged at the point of submission.
if !matches!(event, AppEvent::CodexOp(_)) {
session_log::log_inbound_app_event(&event);
}
if let Err(e) = self.app_event_tx.send(event) {
tracing::error!("failed to send event: {e}");
}
@@ -75,14 +75,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use std::path::PathBuf;
use std::sync::mpsc::channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
}
}
+49 -2
View File
@@ -198,6 +198,12 @@ impl ChatComposer {
self.set_has_focus(has_focus);
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.textarea.insert_str(text);
self.sync_command_popup();
self.sync_file_search_popup();
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
@@ -698,14 +704,15 @@ impl WidgetRef for &ChatComposer {
let token_usage = &token_usage_info.total_token_usage;
hint.push(Span::from(" "));
hint.push(
Span::from(format!("{} tokens used", token_usage.total_tokens))
Span::from(format!("{} tokens used", token_usage.blended_total()))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- (last_token_usage.total_tokens as f32 / context_window as f32
- (last_token_usage.tokens_in_context_window() as f32
/ context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
@@ -1077,6 +1084,46 @@ mod tests {
}
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
match rx.try_recv() {
Ok(AppEvent::DispatchCommand(cmd)) => {
assert_eq!(cmd.command(), "mention");
composer.insert_str("@");
}
Ok(_other) => panic!("unexpected app event"),
Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"),
Err(TryRecvError::Disconnected) => {
panic!("app event channel disconnected")
}
}
assert_eq!(composer.textarea.text(), "@");
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
@@ -1,45 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
/// Minimal rendering-only widget for the transient ring rows.
pub(crate) struct LiveRingWidget {
max_rows: u16,
rows: Vec<Line<'static>>, // newest at the end
}
impl LiveRingWidget {
pub fn new() -> Self {
Self {
max_rows: 3,
rows: Vec::new(),
}
}
pub fn set_max_rows(&mut self, n: u16) {
self.max_rows = n.max(1);
}
pub fn set_rows(&mut self, rows: Vec<Line<'static>>) {
self.rows = rows;
}
pub fn desired_height(&self, _width: u16) -> u16 {
let len = self.rows.len() as u16;
len.min(self.max_rows)
}
}
impl WidgetRef for LiveRingWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let visible = self.rows.len().saturating_sub(self.max_rows as usize);
let slice = &self.rows[visible..];
let para = Paragraph::new(slice.to_vec());
para.render_ref(area, buf);
}
}
+7 -152
View File
@@ -9,7 +9,6 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
mod approval_modal_view;
@@ -18,7 +17,6 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod live_ring_widget;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
@@ -57,10 +55,6 @@ pub(crate) struct BottomPane<'a> {
/// not replace the composer; it augments it.
live_status: Option<StatusIndicatorWidget>,
/// Optional transient ring shown above the composer. This is a rendering-only
/// container used during development before we wire it to ChatWidget events.
live_ring: Option<live_ring_widget::LiveRingWidget>,
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
@@ -88,7 +82,6 @@ impl BottomPane<'_> {
is_task_running: false,
ctrl_c_quit_hint: false,
live_status: None,
live_ring: None,
status_view_active: false,
}
}
@@ -99,26 +92,14 @@ impl BottomPane<'_> {
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
let ring_h = self
.live_ring
.as_ref()
.map(|r| r.desired_height(width))
.unwrap_or(0);
let view_height = if let Some(view) = self.active_view.as_ref() {
// Add a single blank spacer line between live ring and status view when active.
let spacer = if self.live_ring.is_some() && self.status_view_active {
1
} else {
0
};
spacer + view.desired_height(width)
view.desired_height(width)
} else {
self.composer.desired_height(width)
};
overlay_status_h
.saturating_add(ring_h)
.saturating_add(view_height)
.saturating_add(Self::BOTTOM_PAD_LINES)
}
@@ -196,6 +177,11 @@ impl BottomPane<'_> {
}
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.request_redraw();
}
/// Update the status indicator text. Prefer replacing the composer with
/// the StatusIndicatorView so the input pane shows a single-line status
/// like: `▌ Working waiting for model`.
@@ -352,43 +338,11 @@ impl BottomPane<'_> {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
/// Set the rows and cap for the transient live ring overlay.
pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec<Line<'static>>) {
let mut w = live_ring_widget::LiveRingWidget::new();
w.set_max_rows(max_rows);
w.set_rows(rows);
self.live_ring = Some(w);
}
pub(crate) fn clear_live_ring(&mut self) {
self.live_ring = None;
}
// Removed restart_live_status_with_text – no longer used by the current streaming UI.
}
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut y_offset = 0u16;
if let Some(ring) = &self.live_ring {
let live_h = ring.desired_height(area.width).min(area.height);
if live_h > 0 {
let live_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: live_h,
};
ring.render_ref(live_rect, buf);
y_offset = live_h;
}
}
// Spacer between live ring and status view when active
if self.live_ring.is_some() && self.status_view_active && y_offset < area.height {
// Leave one empty line
y_offset = y_offset.saturating_add(1);
}
if let Some(status) = &self.live_status {
let live_h = status
.desired_height(area.width)
@@ -438,15 +392,12 @@ mod tests {
use crate::app_event::AppEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::mpsc::channel;
fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
cwd: PathBuf::from("."),
reason: None,
}
}
@@ -466,103 +417,7 @@ mod tests {
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
}
#[test]
fn live_ring_renders_above_composer() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
enhanced_keys_supported: false,
});
// Provide 4 rows with max_rows=3; only the last 3 should be visible.
pane.set_live_ring_rows(
3,
vec![
Line::from("one".to_string()),
Line::from("two".to_string()),
Line::from("three".to_string()),
Line::from("four".to_string()),
],
);
let area = Rect::new(0, 0, 10, 5);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Extract the first 3 rows and assert they contain the last three lines.
let mut lines: Vec<String> = Vec::new();
for y in 0..3 {
let mut s = String::new();
for x in 0..area.width {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
lines.push(s.trim_end().to_string());
}
assert_eq!(lines, vec!["two", "three", "four"]);
}
#[test]
fn status_indicator_visible_with_live_ring() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
enhanced_keys_supported: false,
});
// Simulate task running which replaces composer with the status indicator.
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the
// status indicator remains visible below them.
pane.set_live_ring_rows(
2,
vec![
Line::from("cot1".to_string()),
Line::from("cot2".to_string()),
],
);
// Allow some frames so the dot animation is present.
std::thread::sleep(std::time::Duration::from_millis(120));
// Height should include both ring rows, 1 spacer, and the 1-line status.
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Top two rows are the live ring.
let mut r0 = String::new();
let mut r1 = String::new();
for x in 0..area.width {
r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(r0.contains("cot1"), "expected first live row: {r0:?}");
assert!(r1.contains("cot2"), "expected second live row: {r1:?}");
// Row 2 is the spacer (blank)
let mut r2 = String::new();
for x in 0..area.width {
r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
}
assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
// Bottom row is the status line; it should contain the left bar and "Working".
let mut r3 = String::new();
for x in 0..area.width {
r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(
r3.contains("Working"),
"expected Working header in status line: {r3:?}"
);
}
// live ring removed; related tests deleted.
#[test]
fn overlay_not_shown_above_approval_modal() {
@@ -1,7 +1,10 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget;
use super::BottomPaneView;
@@ -40,4 +43,10 @@ impl BottomPaneView<'_> for StatusIndicatorView {
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
self.view.render_ref(area, buf);
}
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
if key_event.code == KeyCode::Esc {
self.view.interrupt();
}
}
}
+34 -3
View File
@@ -109,7 +109,7 @@ impl TextArea {
self.wrapped_lines(width).len() as u16
}
#[allow(dead_code)]
#[cfg_attr(not(test), allow(dead_code))]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, &TextAreaState::default())
}
@@ -295,6 +295,20 @@ impl TextArea {
} => {
self.move_cursor_right();
}
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_left();
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_right();
}
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
@@ -359,8 +373,9 @@ impl TextArea {
} => {
self.move_cursor_to_end_of_line(true);
}
o => {
tracing::debug!("Unhandled key event in TextArea: {:?}", o);
_o => {
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
}
}
}
@@ -907,6 +922,22 @@ mod tests {
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn control_b_and_f_move_cursor() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let mut t = ta_with("abcd");
t.set_cursor(1);
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 2);
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 1);
}
#[test]
fn cursor_vertical_movement_across_lines_and_bounds() {
let mut t = ta_with("short\nloooooooooong\nmid");
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
use std::sync::Arc;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::protocol::Op;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
/// Spawn the agent bootstrapper and op forwarding loop, returning the
/// `UnboundedSender<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ConversationManager>,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx.clone();
tokio::spawn(async move {
let NewConversation {
conversation_id: _,
conversation,
session_configured,
} = match server.new_conversation(config).await {
Ok(v) => v,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
};
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_core::protocol::Event {
// The `id` does not matter for rendering, so we can use a fake value.
id: "".to_string(),
msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let conversation_clone = conversation.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = conversation_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = conversation.next_event().await {
app_event_tx_clone.send(AppEvent::CodexEvent(event));
}
});
codex_op_tx
}
+89
View File
@@ -0,0 +1,89 @@
use std::collections::VecDeque;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyEndEvent;
use super::ChatWidget;
#[derive(Debug)]
pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
McpBegin(McpToolCallBeginEvent),
McpEnd(McpToolCallEndEvent),
PatchEnd(PatchApplyEndEvent),
}
#[derive(Default)]
pub(crate) struct InterruptManager {
queue: VecDeque<QueuedInterrupt>,
}
impl InterruptManager {
pub(crate) fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
#[inline]
pub(crate) fn is_empty(&self) -> bool {
self.queue.is_empty()
}
pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev));
}
pub(crate) fn push_apply_patch_approval(
&mut self,
id: String,
ev: ApplyPatchApprovalRequestEvent,
) {
self.queue
.push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
}
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
}
pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
}
pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
self.queue.push_back(QueuedInterrupt::McpBegin(ev));
}
pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
self.queue.push_back(QueuedInterrupt::McpEnd(ev));
}
pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
}
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
while let Some(q) = self.queue.pop_front() {
match q {
QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),
QueuedInterrupt::ApplyPatchApproval(id, ev) => {
chat.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
}
}
}
}
+925
View File
@@ -0,0 +1,925 @@
#![allow(clippy::unwrap_used, clippy::expect_used, unnameable_test_items)]
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::TaskCompleteEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use tokio::sync::mpsc::unbounded_channel;
fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
codex_core::config::Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
std::env::temp_dir(),
)
.expect("config")
}
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80;
let height: u16 = 2000;
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
// Simulate a streaming answer without any newline characters.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Now simulate the final AgentMessage which should flush the pending line immediately.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Drain history insertions and verify the final line is present.
let cells = drain_insert_history(&rx);
assert!(
cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("codex")
}),
"expected 'codex' header to be emitted",
);
let found_final = cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("Hi! How can I help with codex-rs or anything else today?")
});
assert!(
found_final,
"expected final answer text to be flushed to history"
);
}
#[tokio::test(flavor = "current_thread")]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let cfg = test_config();
let conversation_manager = Arc::new(ConversationManager::default());
let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false);
// Basic construction sanity.
let _ = &mut w;
}
// --- Helpers for tests that need direct construction and event draining ---
fn make_chatwidget_manual() -> (
ChatWidget<'static>,
std::sync::mpsc::Receiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
let (tx_raw, rx) = channel::<AppEvent>();
let app_event_tx = AppEventSender::new(tx_raw);
let (op_tx, op_rx) = unbounded_channel::<Op>();
let cfg = test_config();
let bottom = BottomPane::new(BottomPaneParams {
app_event_tx: app_event_tx.clone(),
has_input_focus: true,
enhanced_keys_supported: false,
});
let widget = ChatWidget {
app_event_tx,
codex_op_tx: op_tx,
bottom_pane: bottom,
active_exec_cell: None,
config: cfg.clone(),
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
stream: StreamController::new(cfg),
last_stream_kind: None,
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
};
(widget, rx, op_rx)
}
fn drain_insert_history(
rx: &std::sync::mpsc::Receiver<AppEvent>,
) -> Vec<Vec<ratatui::text::Line<'static>>> {
let mut out = Vec::new();
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
out.push(lines);
}
}
out
}
fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
let mut s = String::new();
for line in lines {
for span in &line.spans {
s.push_str(&span.content);
}
s.push('\n');
}
s
}
fn open_fixture(name: &str) -> std::fs::File {
// 1) Prefer fixtures within this crate
{
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push(name);
if let Ok(f) = File::open(&p) {
return f;
}
}
// 2) Fallback to parent (workspace root)
{
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("..");
p.push(name);
if let Ok(f) = File::open(&p) {
return f;
}
}
// 3) Last resort: CWD
File::open(name).expect("open fixture file")
}
#[test]
fn exec_history_cell_shows_working_then_completed() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Begin command
chat.handle_codex_event(Event {
id: "call-1".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-1".into(),
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
cmd: vec!["echo".into(), "done".into()],
}],
}),
});
// End command successfully
chat.handle_codex_event(Event {
id: "call-1".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-1".into(),
stdout: "done".into(),
stderr: String::new(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
}),
});
let cells = drain_insert_history(&rx);
assert_eq!(
cells.len(),
1,
"expected only the completed exec cell to be inserted into history"
);
let blob = lines_to_single_string(&cells[0]);
assert!(
blob.contains("Completed"),
"expected completed exec cell to show Completed header: {blob:?}"
);
}
#[test]
fn exec_history_cell_shows_working_then_failed() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Begin command
chat.handle_codex_event(Event {
id: "call-2".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-2".into(),
command: vec!["bash".into(), "-lc".into(), "false".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
cmd: vec!["false".into()],
}],
}),
});
// End command with failure
chat.handle_codex_event(Event {
id: "call-2".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-2".into(),
stdout: String::new(),
stderr: "error".into(),
exit_code: 2,
duration: std::time::Duration::from_millis(7),
}),
});
let cells = drain_insert_history(&rx);
assert_eq!(
cells.len(),
1,
"expected only the completed exec cell to be inserted into history"
);
let blob = lines_to_single_string(&cells[0]);
assert!(
blob.contains("Failed (exit 2)"),
"expected completed exec cell to show Failed header with exit code: {blob:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn binary_size_transcript_matches_ideal_fixture() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80;
let height: u16 = 2000;
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
// Replay the recorded session into the widget and collect transcript
let file = open_fixture("binary-size-log.jsonl");
let reader = BufReader::new(file);
let mut transcript = String::new();
let mut ansi: Vec<u8> = Vec::new();
for line in reader.lines() {
let line = line.expect("read line");
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let Ok(v): Result<serde_json::Value, _> = serde_json::from_str(&line) else {
continue;
};
let Some(dir) = v.get("dir").and_then(|d| d.as_str()) else {
continue;
};
if dir != "to_tui" {
continue;
}
let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
continue;
};
match kind {
"codex_event" => {
if let Some(payload) = v.get("payload") {
let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
chat.handle_codex_event(ev);
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = app_ev {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
}
}
}
"app_event" => {
if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) {
if variant == "CommitTick" {
chat.on_commit_tick();
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = app_ev {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
}
}
}
}
_ => {}
}
}
// Read the ideal fixture as-is
let mut f = open_fixture("ideal-binary-response.txt");
let mut ideal = String::new();
f.read_to_string(&mut ideal)
.expect("read ideal-binary-response.txt");
// Normalize line endings for Windows vs. Unix checkouts
let ideal = ideal.replace("\r\n", "\n");
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
let mut parser = vt100::Parser::new(height, width, 0);
parser.process(&ansi);
let mut lines: Vec<String> = Vec::with_capacity(height as usize);
for row in 0..height {
let mut s = String::with_capacity(width as usize);
for col in 0..width {
if let Some(cell) = parser.screen().cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
s.push(ch);
} else {
s.push(' ');
}
} else {
s.push(' ');
}
}
// Trim trailing spaces to match plain text fixture
lines.push(s.trim_end().to_string());
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
// Compare only after the last session banner marker, and start at the next 'thinking' line.
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
let last_marker_line_idx = lines
.iter()
.rposition(|l| l.starts_with(MARKER_PREFIX))
.expect("marker not found in visible output");
let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
.find(|&idx| lines[idx].trim_start() == "thinking")
.expect("no 'thinking' line found after marker");
let mut compare_lines: Vec<String> = Vec::new();
// Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
let visible_after = compare_lines.join("\n");
// Optionally update the fixture when env var is set
if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push("ideal-binary-response.txt");
std::fs::write(&p, &visible_after).expect("write updated ideal fixture");
return;
}
// Exact equality with pretty diff on failure
assert_eq!(visible_after, ideal);
}
#[test]
fn final_longer_answer_after_single_char_delta_is_complete() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Simulate a stray delta without newline (e.g., punctuation).
chat.handle_codex_event(Event {
id: "sub-x".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "?".into() }),
});
// Now send the full final answer with no newline.
let full = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?";
chat.handle_codex_event(Event {
id: "sub-x".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: full.into(),
}),
});
// Drain and assert the full message appears in history.
let cells = drain_insert_history(&rx);
let mut found = false;
for lines in &cells {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
if s.contains(full) {
found = true;
break;
}
}
assert!(
found,
"expected full final message to be flushed to history, cells={:?}",
cells.len()
);
}
#[test]
fn apply_patch_events_emit_history_cells() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// 1) Approval request -> proposed patch summary cell
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
let ev = ApplyPatchApprovalRequestEvent {
call_id: "c1".into(),
changes,
reason: None,
grant_root: None,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
let cells = drain_insert_history(&rx);
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("proposed patch"),
"missing proposed patch header: {blob:?}"
);
// 2) Begin apply -> applying patch cell
let mut changes2 = HashMap::new();
changes2.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
let begin = PatchApplyBeginEvent {
call_id: "c1".into(),
auto_approved: true,
changes: changes2,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(begin),
});
let cells = drain_insert_history(&rx);
assert!(!cells.is_empty(), "expected applying patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Applying patch"),
"missing applying patch header: {blob:?}"
);
// 3) End apply success -> success cell
let end = PatchApplyEndEvent {
call_id: "c1".into(),
stdout: "ok\n".into(),
stderr: String::new(),
success: true,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyEnd(end),
});
let cells = drain_insert_history(&rx);
assert!(!cells.is_empty(), "expected applied patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Applied patch"),
"missing applied patch header: {blob:?}"
);
}
#[test]
fn apply_patch_approval_sends_op_with_submission_id() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Simulate receiving an approval request with a distinct submission id and call id
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("file.rs"),
FileChange::Add {
content: "fn main(){}\n".into(),
},
);
let ev = ApplyPatchApprovalRequestEvent {
call_id: "call-999".into(),
changes,
reason: None,
grant_root: None,
};
chat.handle_codex_event(Event {
id: "sub-123".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
// Approve via key press 'y'
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
// Expect a CodexOp with PatchApproval carrying the submission id, not call id
let mut found = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev {
assert_eq!(id, "sub-123");
assert!(matches!(
decision,
codex_core::protocol::ReviewDecision::Approved
));
found = true;
break;
}
}
assert!(found, "expected PatchApproval op to be sent");
}
#[test]
fn apply_patch_full_flow_integration_like() {
let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
// 1) Backend requests approval
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "call-1".into(),
changes,
reason: None,
grant_root: None,
}),
});
// 2) User approves via 'y' and App receives a CodexOp
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut maybe_op: Option<Op> = None;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::CodexOp(op) = app_ev {
maybe_op = Some(op);
break;
}
}
let op = maybe_op.expect("expected CodexOp after key press");
// 3) App forwards to widget.submit_op, which pushes onto codex_op_tx
chat.submit_op(op);
let forwarded = op_rx
.try_recv()
.expect("expected op forwarded to codex channel");
match forwarded {
Op::PatchApproval { id, decision } => {
assert_eq!(id, "sub-xyz");
assert!(matches!(
decision,
codex_core::protocol::ReviewDecision::Approved
));
}
other => panic!("unexpected op forwarded: {other:?}"),
}
// 4) Simulate patch begin/end events from backend; ensure history cells are emitted
let mut changes2 = HashMap::new();
changes2.insert(
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "call-1".into(),
auto_approved: false,
changes: changes2,
}),
});
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
call_id: "call-1".into(),
stdout: String::from("ok"),
stderr: String::new(),
success: true,
}),
});
}
#[test]
fn apply_patch_untrusted_shows_approval_modal() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Ensure approval policy is untrusted (OnRequest)
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
// Simulate a patch approval request from backend
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("a.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "call-1".into(),
changes,
reason: None,
grant_root: None,
}),
});
// Render and ensure the approval modal title is present
let area = ratatui::layout::Rect::new(0, 0, 80, 12);
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut contains_title = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("Apply changes?") {
contains_title = true;
break;
}
}
assert!(
contains_title,
"expected approval modal to be visible with title 'Apply changes?'"
);
}
#[test]
fn apply_patch_request_shows_diff_summary() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Ensure we are in OnRequest so an approval is surfaced
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
// Simulate backend asking to apply a patch adding two lines to README.md
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
// Two lines (no trailing empty line counted)
content: "line one\nline two\n".into(),
},
);
chat.handle_codex_event(Event {
id: "sub-apply".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "call-apply".into(),
changes,
reason: None,
grant_root: None,
}),
});
// Drain history insertions and verify the diff summary is present
let cells = drain_insert_history(&rx);
assert!(
!cells.is_empty(),
"expected a history cell with the proposed patch summary"
);
let blob = lines_to_single_string(cells.last().unwrap());
// Header should summarize totals
assert!(
blob.contains("proposed patch to 1 file (+2 -0)"),
"missing or incorrect diff header: {blob:?}"
);
// Per-file summary line should include the file path and counts
assert!(
blob.contains("README.md (+2 -0)"),
"missing per-file diff summary: {blob:?}"
);
}
#[test]
fn plan_update_renders_history_cell() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
let update = UpdatePlanArgs {
explanation: Some("Adapting plan".to_string()),
plan: vec![
PlanItemArg {
step: "Explore codebase".into(),
status: StepStatus::Completed,
},
PlanItemArg {
step: "Implement feature".into(),
status: StepStatus::InProgress,
},
PlanItemArg {
step: "Write tests".into(),
status: StepStatus::Pending,
},
],
};
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::PlanUpdate(update),
});
let cells = drain_insert_history(&rx);
assert!(!cells.is_empty(), "expected plan update cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Update plan"),
"missing plan header: {blob:?}"
);
assert!(blob.contains("Explore codebase"));
assert!(blob.contains("Implement feature"));
assert!(blob.contains("Write tests"));
}
#[test]
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Answer: no header until a newline commit
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hello".into(),
}),
});
let mut saw_codex_pre = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<Vec<_>>()
.join("");
if s.contains("codex") {
saw_codex_pre = true;
break;
}
}
}
assert!(
!saw_codex_pre,
"answer header should not be emitted before first newline commit"
);
// Newline arrives, then header is emitted
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "!\n".into(),
}),
});
chat.on_commit_tick();
let mut saw_codex_post = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<Vec<_>>()
.join("");
if s.contains("codex") {
saw_codex_post = true;
break;
}
}
}
assert!(
saw_codex_post,
"expected 'codex' header to be emitted after first newline commit"
);
// Reasoning: header immediately
let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
chat2.handle_codex_event(Event {
id: "sub-b".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "Thinking".into(),
}),
});
let mut saw_thinking = false;
while let Ok(ev) = rx2.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<Vec<_>>()
.join("");
if s.contains("thinking") {
saw_thinking = true;
break;
}
}
}
assert!(
saw_thinking,
"expected 'thinking' header to be emitted at stream start"
);
}
#[test]
fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Begin turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TaskStarted,
});
// First finalized assistant message
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "First message".into(),
}),
});
// Second finalized assistant message in the same turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Second message".into(),
}),
});
// End turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
let cells = drain_insert_history(&rx);
let mut header_count = 0usize;
let mut combined = String::new();
for lines in &cells {
for l in lines {
for sp in &l.spans {
let s = &sp.content;
if s == "codex" {
header_count += 1;
}
combined.push_str(s);
}
combined.push('\n');
}
}
assert_eq!(
header_count,
2,
"expected two 'codex' headers for two AgentMessage events in one turn; cells={:?}",
cells.len()
);
assert!(
combined.contains("First message"),
"missing first message: {combined}"
);
assert!(
combined.contains("Second message"),
"missing second message: {combined}"
);
let first_idx = combined.find("First message").unwrap();
let second_idx = combined.find("Second message").unwrap();
assert!(first_idx < second_idx, "messages out of order: {combined}");
}
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;
+431
View File
@@ -0,0 +1,431 @@
use crossterm::terminal;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::common::DEFAULT_WRAP_COLS;
use codex_core::protocol::FileChange;
use crate::history_cell::PatchEventType;
const SPACES_AFTER_LINE_NUMBER: usize = 6;
// Internal representation for diff line rendering
enum DiffLineType {
Insert,
Delete,
Context,
}
pub(crate) fn create_diff_summary(
title: &str,
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
) -> Vec<RtLine<'static>> {
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(|h| h.lines())
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
_ => (a, d),
})
} else {
// Fallback: manual scan to preserve counts even for unparsable diffs
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
}
(adds, dels)
}
};
let mut files: Vec<FileSummary> = Vec::new();
for (path, change) in changes.iter() {
match change {
FileChange::Add { content } => files.push(FileSummary {
display_path: path.display().to_string(),
added: content.lines().count(),
removed: 0,
}),
FileChange::Delete => files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed: std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0),
}),
FileChange::Update {
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{} → {}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { " ⎿ " } else { " " };
line.spans.insert(0, prefix.into());
line.spans
.iter_mut()
.for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
out.push(line);
}
let show_details = matches!(
event_type,
PatchEventType::ApplyBegin {
auto_approved: true
} | PatchEventType::ApprovalRequest
);
if show_details {
out.extend(render_patch_details(changes));
}
out
}
fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols: usize = terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(DEFAULT_WRAP_COLS.into());
for (index, (path, change)) in changes.iter().enumerate() {
let is_first_file = index == 0;
// Add separator only between files (not at the very start)
if !is_first_file {
out.push(RtLine::from(vec![
RtSpan::raw(" "),
RtSpan::styled("...", style_dim()),
]));
}
match change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
let ln = i + 1;
out.extend(push_wrapped_diff_line(
ln,
DiffLineType::Insert,
raw,
term_cols,
));
}
}
FileChange::Delete => {
let original = std::fs::read_to_string(path).unwrap_or_default();
for (i, raw) in original.lines().enumerate() {
let ln = i + 1;
out.extend(push_wrapped_diff_line(
ln,
DiffLineType::Delete,
raw,
term_cols,
));
}
}
FileChange::Update {
unified_diff,
move_path: _,
} => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
for h in patch.hunks() {
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
term_cols,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
term_cols,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
term_cols,
));
old_ln += 1;
new_ln += 1;
}
}
}
}
}
}
}
out.push(RtLine::from(RtSpan::raw("")));
}
out
}
fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
text: &str,
term_cols: usize,
) -> Vec<RtLine<'static>> {
let indent = " ";
let ln_str = line_number.to_string();
let mut remaining_text: &str = text;
// Reserve a fixed number of spaces after the line number so that content starts
// at a consistent column. The sign ("+"/"-") is rendered as part of the content
// and colored with the same foreground as the edited text, not as a separate
// dimmed column.
let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
let mut first = true;
let (sign_opt, line_style) = match kind {
DiffLineType::Insert => (Some('+'), Some(style_add())),
DiffLineType::Delete => (Some('-'), Some(style_del())),
DiffLineType::Context => (None, None),
};
let mut lines: Vec<RtLine<'static>> = Vec::new();
while !remaining_text.is_empty() {
let prefix_cols = if first {
first_prefix_cols
} else {
cont_prefix_cols
};
// Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1);
let split_at_byte_index = remaining_text
.char_indices()
.nth(available_content_cols)
.map(|(i, _)| i)
.unwrap_or_else(|| remaining_text.len());
let (chunk, rest) = remaining_text.split_at(split_at_byte_index);
remaining_text = rest;
if first {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(indent));
spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
// Prefix the content with the sign if it is an insertion or deletion, and color
// the sign and content with the same foreground as the edited text.
let display_chunk = match sign_opt {
Some(sign_char) => format!("{sign_char}{chunk}"),
None => chunk.to_string(),
};
let content_span = match line_style {
Some(style) => RtSpan::styled(display_chunk, style),
None => RtSpan::raw(display_chunk),
};
spans.push(content_span);
let mut line = RtLine::from(spans);
if let Some(style) = line_style {
line.style = line.style.patch(style);
}
lines.push(line);
first = false;
} else {
let hang_prefix = format!(
"{indent}{}{}",
" ".repeat(ln_str.len()),
" ".repeat(gap_after_ln)
);
let content_span = match line_style {
Some(style) => RtSpan::styled(chunk.to_string(), style),
None => RtSpan::raw(chunk.to_string()),
};
let mut line = RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]);
if let Some(style) = line_style {
line.style = line.style.patch(style);
}
lines.push(line);
}
}
lines
}
fn style_dim() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
fn style_add() -> Style {
Style::default().fg(Color::Green)
}
fn style_del() -> Style {
Style::default().fg(Color::Red)
}
#[allow(clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use crate::text_block::TextBlock;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
let cell = HistoryCell::PendingPatch {
view: TextBlock::new(lines),
};
terminal
.draw(|f| f.render_widget_ref(&cell, f.area()))
.expect("draw");
assert_snapshot!(name, terminal.backend());
}
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
content: "first line\nsecond line\n".to_string(),
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
#[test]
fn ui_snapshot_update_details_with_rename() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("src/lib.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("src/lib_new.rs")),
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
#[test]
fn ui_snapshot_wrap_behavior_insert() {
// Narrow width to force wrapping within our diff line rendering
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
// Call the wrapping function directly so we can precisely control the width
let lines =
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
// Render into a small terminal to capture the visual layout
snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
}
}
+260 -231
View File
@@ -1,3 +1,5 @@
use crate::colors::LIGHT_BLUE;
use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand;
@@ -8,6 +10,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::config::Config;
use codex_core::parse_command::ParsedCommand;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
@@ -26,8 +29,6 @@ use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -37,18 +38,13 @@ use std::path::PathBuf;
use std::time::Duration;
use tracing::error;
#[derive(Clone)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
pub(crate) enum PatchEventType {
ApprovalRequest,
ApplyBegin { auto_approved: bool },
@@ -69,28 +65,37 @@ fn line_to_static(line: &Line) -> Line<'static> {
}
}
pub(crate) struct ExecCell {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
}
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) enum HistoryCell {
/// Welcome message.
WelcomeMessage { view: TextBlock },
WelcomeMessage {
view: TextBlock,
},
/// Message from the user.
UserPrompt { view: TextBlock },
UserPrompt {
view: TextBlock,
},
// AgentMessage and AgentReasoning variants were unused and have been removed.
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: TextBlock },
/// Completed exec tool call.
CompletedExecCommand { view: TextBlock },
Exec(ExecCell),
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall { view: TextBlock },
ActiveMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where we show the result serialized as JSON.
CompletedMcpToolCall { view: TextBlock },
CompletedMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
@@ -100,40 +105,60 @@ pub(crate) enum HistoryCell {
// resized version avoids doing the potentially expensive rescale twice
// because the scroll-view first calls `height()` for layouting and then
// `render_window()` for painting.
CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
},
/// Background event.
BackgroundEvent { view: TextBlock },
BackgroundEvent {
view: TextBlock,
},
/// Output from the `/diff` command.
GitDiffOutput { view: TextBlock },
GitDiffOutput {
view: TextBlock,
},
/// Output from the `/status` command.
StatusOutput { view: TextBlock },
StatusOutput {
view: TextBlock,
},
/// Output from the `/prompts` command.
PromptsOutput { view: TextBlock },
PromptsOutput {
view: TextBlock,
},
/// Error event from the backend.
ErrorEvent { view: TextBlock },
ErrorEvent {
view: TextBlock,
},
/// Info describing the newly-initialized session.
SessionInfo { view: TextBlock },
SessionInfo {
view: TextBlock,
},
/// A pending code patch that is awaiting user approval. Mirrors the
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
/// behaviour of `ExecCell` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
PendingPatch { view: TextBlock },
PendingPatch {
view: TextBlock,
},
/// A human‑friendly rendering of the model's current plan and step
/// statuses provided via the `update_plan` tool.
PlanUpdate { view: TextBlock },
PlanUpdate {
view: TextBlock,
},
/// Result of applying a patch (success or failure) with optional output.
PatchApplyResult { view: TextBlock },
PatchApplyResult {
view: TextBlock,
},
}
const TOOL_CALL_MAX_LINES: usize = 3;
const TOOL_CALL_MAX_LINES: usize = 5;
fn title_case(s: &str) -> String {
if s.is_empty() {
@@ -159,7 +184,8 @@ fn pretty_provider_name(id: &str) -> String {
impl HistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
/// one‑shot insertion into the terminal scrollback. Image cells are
/// represented with a simple placeholder for now.
/// represented with a simple placeholder.
/// These lines are also rendered directly by ratatui wrapped in a Paragraph.
pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
match self {
HistoryCell::WelcomeMessage { view }
@@ -170,15 +196,18 @@ impl HistoryCell {
| HistoryCell::PromptsOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::PatchApplyResult { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
}) => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
@@ -186,6 +215,16 @@ impl HistoryCell {
}
}
pub(crate) fn new_background_event(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim()));
lines.extend(message.lines().map(|line| ansi_escape_line(line).dim()));
lines.push(Line::from(""));
HistoryCell::BackgroundEvent {
view: TextBlock::new(lines),
}
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
@@ -261,79 +300,111 @@ impl HistoryCell {
}
}
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);
pub(crate) fn new_active_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> Self {
HistoryCell::new_exec_cell(command, parsed, None)
}
let mut lines: Vec<Line<'static>> = Vec::new();
let mut iter = command_escaped.lines();
if let Some(first) = iter.next() {
lines.push(Line::from(vec![
"▌ ".cyan(),
"Running command ".magenta(),
first.to_string().into(),
]));
} else {
lines.push(Line::from(vec!["▌ ".cyan(), "Running command".magenta()]));
}
for cont in iter {
lines.push(Line::from(cont.to_string()));
}
lines.push(Line::from(""));
pub(crate) fn new_completed_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
output: CommandOutput,
) -> Self {
HistoryCell::new_exec_cell(command, parsed, Some(output))
}
HistoryCell::ActiveExecCommand {
view: TextBlock::new(lines),
fn new_exec_cell(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
output: Option<CommandOutput>,
) -> Self {
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
})
}
fn exec_command_lines(
command: &[String],
parsed: &[ParsedCommand],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
match parsed.is_empty() {
true => HistoryCell::new_exec_command_generic(command, output),
false => HistoryCell::new_parsed_command(parsed, output),
}
}
pub(crate) fn new_completed_exec_command(command: Vec<String>, output: CommandOutput) -> Self {
let CommandOutput {
exit_code,
stdout,
stderr,
} = output;
fn new_parsed_command(
parsed_commands: &[ParsedCommand],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = vec![match output {
None => Line::from("⚙︎ Working".magenta().bold()),
Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()),
Some(o) => Line::from(format!("✗ Failed (exit {})", o.exit_code).red().bold()),
}];
for (i, parsed) in parsed_commands.iter().enumerate() {
let text = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::ListFiles { cmd, path } => match path {
Some(p) => format!("📂 {p}"),
None => format!("📂 {}", shlex_join_safe(cmd)),
},
ParsedCommand::Search { query, path, cmd } => match (query, path) {
(Some(q), Some(p)) => format!("🔎 {q} in {p}"),
(Some(q), None) => format!("🔎 {q}"),
(None, Some(p)) => format!("🔎 {p}"),
(None, None) => format!("🔎 {}", shlex_join_safe(cmd)),
},
ParsedCommand::Format { .. } => "✨ Formatting".to_string(),
ParsedCommand::Test { cmd } => format!("🧪 {}", shlex_join_safe(cmd)),
ParsedCommand::Lint { cmd, .. } => format!("🧹 {}", shlex_join_safe(cmd)),
ParsedCommand::Unknown { cmd } => format!("⌨️ {}", shlex_join_safe(cmd)),
};
let first_prefix = if i == 0 { " L " } else { " " };
for (j, line_text) in text.lines().enumerate() {
let prefix = if j == 0 { first_prefix } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
Span::styled(line_text.to_string(), Style::default().fg(LIGHT_BLUE)),
]));
}
}
lines.extend(output_lines(output, true, false));
lines.push(Line::from(""));
lines
}
fn new_exec_command_generic(
command: &[String],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(&command);
let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines();
if let Some(first) = cmd_lines.next() {
lines.push(Line::from(vec![
"⚡ Ran command ".magenta(),
"⚡ Running ".to_string().magenta(),
first.to_string().into(),
]));
} else {
lines.push(Line::from("⚡ Ran command".magenta()));
lines.push(Line::from("⚡ Running".to_string().magenta()));
}
for cont in cmd_lines {
lines.push(Line::from(cont.to_string()));
}
let src = if exit_code == 0 { stdout } else { stderr };
lines.extend(output_lines(output, false, true));
let mut lines_iter = src.lines();
for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if idx == 0 { " ⎿ " } else { " " };
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(line);
}
let remaining = lines_iter.count();
if remaining > 0 {
let mut more = Line::from(format!("... +{remaining} lines"));
// Continuation/ellipsis is treated as a subsequent line for prefixing
more.spans.insert(0, " ".into());
more.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(more);
}
lines.push(Line::from(""));
HistoryCell::CompletedExecCommand {
view: TextBlock::new(lines),
}
lines
}
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self {
@@ -392,7 +463,7 @@ impl HistoryCell {
}
pub(crate) fn new_completed_mcp_tool_call(
num_cols: u16,
num_cols: usize,
invocation: McpInvocation,
duration: Duration,
success: bool,
@@ -430,7 +501,7 @@ impl HistoryCell {
format_and_truncate_tool_result(
&text.text,
TOOL_CALL_MAX_LINES,
num_cols as usize,
num_cols,
)
}
mcp_types::ContentBlock::ImageContent(_) => {
@@ -476,17 +547,6 @@ impl HistoryCell {
view: TextBlock::new(lines),
}
}
// allow dead code for now. maybe we'll use it again.
#[allow(dead_code)]
pub(crate) fn new_background_event(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim()));
lines.extend(message.lines().map(|line| ansi_escape_line(line).dim()));
lines.push(Line::from(""));
HistoryCell::BackgroundEvent {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_diff_output(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -682,10 +742,10 @@ impl HistoryCell {
let mut header: Vec<Span> = Vec::new();
header.push(Span::raw("📋"));
header.push(Span::styled(
" Updated",
" Update plan",
Style::default().add_modifier(Modifier::BOLD).magenta(),
));
header.push(Span::raw(" to do list ["));
header.push(Span::raw(" ["));
if filled > 0 {
header.push(Span::styled(
"█".repeat(filled),
@@ -773,7 +833,7 @@ impl HistoryCell {
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
) -> Self {
let title = match event_type {
let title = match &event_type {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
@@ -791,13 +851,7 @@ impl HistoryCell {
}
};
let summary_lines = create_diff_summary(title, changes);
let mut lines: Vec<Line<'static>> = Vec::new();
for line in summary_lines {
lines.push(line);
}
let mut lines: Vec<Line<'static>> = create_diff_summary(title, &changes, event_type);
lines.push(Line::from(""));
@@ -813,7 +867,32 @@ impl HistoryCell {
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
let mut iter = stderr.lines();
lines.extend(output_lines(
Some(&CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr,
}),
true,
true,
));
}
lines.push(Line::from(""));
HistoryCell::PatchApplyResult {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_patch_apply_success(stdout: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
// Success title
lines.push(Line::from("✓ Applied patch".magenta().bold()));
if !stdout.trim().is_empty() {
let mut iter = stdout.lines();
for (i, raw) in iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let prefix = if i == 0 { " ⎿ " } else { " " };
let s = format!("{prefix}{raw}");
@@ -842,131 +921,58 @@ impl WidgetRef for &HistoryCell {
}
}
fn create_diff_summary(title: &str, changes: HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut files: Vec<FileSummary> = Vec::new();
// Count additions/deletions from a unified diff body
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
let mut adds = 0usize;
let mut dels = 0usize;
for hunk in patch.hunks() {
for line in hunk.lines() {
match line {
diffy::Line::Insert(_) => adds += 1,
diffy::Line::Delete(_) => dels += 1,
_ => {}
}
}
}
(adds, dels)
} else {
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
}
(adds, dels)
}
fn output_lines(
output: Option<&CommandOutput>,
only_err: bool,
include_angle_pipe: bool,
) -> Vec<Line<'static>> {
let CommandOutput {
exit_code,
stdout,
stderr,
} = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output,
None => return vec![],
};
for (path, change) in &changes {
use codex_core::protocol::FileChange::*;
match change {
Add { content } => {
let added = content.lines().count();
files.push(FileSummary {
display_path: path.display().to_string(),
added,
removed: 0,
});
}
Delete => {
let removed = std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0);
files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed,
});
}
Update {
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{} → {}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
let src = if *exit_code == 0 { stdout } else { stderr };
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let limit = TOOL_CALL_MAX_LINES;
let mut out = Vec::new();
let head_end = total.min(limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if i == 0 && include_angle_pipe {
" ⎿ "
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
// If we will ellipsize less than the limit, just show it.
let show_ellipsis = total > 2 * limit;
if show_ellipsis {
let omitted = total - 2 * limit;
out.push(Line::from(format!("… +{omitted} lines")));
}
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { " ⎿ " } else { " " };
line.spans.insert(0, prefix.into());
let tail_start = if show_ellipsis {
total - limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
line.spans.insert(0, " ".into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
@@ -996,3 +1002,26 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
];
Line::from(invocation_spans)
}
fn shlex_join_safe(command: &[String]) -> String {
match shlex::try_join(command.iter().map(|s| s.as_str())) {
Ok(cmd) => cmd,
Err(_) => command.join(" "),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsed_command_with_newlines_starts_each_line_at_origin() {
let parsed = vec![ParsedCommand::Unknown {
cmd: vec!["printf".into(), "foo\nbar".into()],
}];
let lines = HistoryCell::exec_command_lines(&[], &parsed, None);
assert!(lines.len() >= 3);
assert_eq!(lines[1].spans[0].content, " L ");
assert_eq!(lines[2].spans[0].content, " ");
}
}
+156 -35
View File
@@ -18,6 +18,8 @@ use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
@@ -40,7 +42,10 @@ pub fn insert_history_lines_to_writer<B, W>(
let mut area = terminal.get_frame().area();
let wrapped_lines = wrapped_line_count(&lines, area.width);
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines(&lines, area.width.max(1));
let wrapped_lines = wrapped.len() as u16;
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
@@ -91,7 +96,7 @@ pub fn insert_history_lines_to_writer<B, W>(
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(writer, MoveTo(0, cursor_top)).ok();
for line in lines {
for line in wrapped {
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
}
@@ -104,36 +109,6 @@ pub fn insert_history_lines_to_writer<B, W>(
}
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
let mut count = 0;
for line in lines {
count += line_height(line, width);
}
count
}
fn line_height(line: &Line, width: u16) -> u16 {
// Use the same visible-width slicing semantics as the live row builder so
// our pre-scroll estimation matches how rows will actually wrap.
let w = width.max(1) as usize;
let mut rows = 0u16;
let mut remaining = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
while !remaining.is_empty() {
let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w);
rows = rows.saturating_add(1);
if taken >= remaining.len() {
break;
}
remaining = suffix.to_string();
}
rows.max(1)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);
@@ -282,6 +257,126 @@ where
)
}
/// Word-aware wrapping for a list of `Line`s preserving styles.
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
let mut out = Vec::new();
let w = width.max(1) as usize;
for line in lines {
out.extend(word_wrap_line(line, w));
}
out
}
fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![to_owned_line(line)];
}
// Concatenate content and keep span boundaries for later re-slicing.
let mut flat = String::new();
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
let mut cursor = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let start = cursor;
flat.push_str(text);
cursor += text.len();
span_bounds.push((start, cursor, s.style));
}
// Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
let opts = TwOptions::new(width)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
let wrapped = textwrap::wrap(&flat, &opts);
if wrapped.len() <= 1 {
return vec![to_owned_line(line)];
}
// Map wrapped pieces back to byte ranges in `flat` sequentially.
let mut start_cursor = 0usize;
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
for piece in wrapped {
let piece_str: &str = &piece;
if piece_str.is_empty() {
out.push(Line {
style: line.style,
alignment: line.alignment,
spans: Vec::new(),
});
continue;
}
// Find the next occurrence of piece_str at or after start_cursor.
// textwrap preserves order, so a linear scan is sufficient.
if let Some(rel) = flat[start_cursor..].find(piece_str) {
let s = start_cursor + rel;
let e = s + piece_str.len();
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
} else {
// Fallback: slice by length from cursor.
let s = start_cursor;
let e = (start_cursor + piece_str.len()).min(flat.len());
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
}
}
out
}
fn to_owned_line(l: &Line<'_>) -> Line<'static> {
Line {
style: l.style,
alignment: l.alignment,
spans: l
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
fn slice_line_spans(
original: &Line<'_>,
span_bounds: &[(usize, usize, ratatui::style::Style)],
start_byte: usize,
end_byte: usize,
) -> Line<'static> {
let mut acc: Vec<Span<'static>> = Vec::new();
for (i, (s, e, style)) in span_bounds.iter().enumerate() {
if *e <= start_byte {
continue;
}
if *s >= end_byte {
break;
}
let seg_start = start_byte.max(*s);
let seg_end = end_byte.min(*e);
if seg_end > seg_start {
let local_start = seg_start - *s;
let local_end = seg_end - *s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
acc.push(Span {
style: *style,
content: std::borrow::Cow::Owned(slice.to_string()),
});
}
if *e >= end_byte {
break;
}
}
Line {
style: original.style,
alignment: original.alignment,
spans: acc,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
@@ -318,8 +413,34 @@ mod tests {
#[test]
fn line_height_counts_double_width_emoji() {
let line = Line::from("😀😀😀"); // each emoji ~ width 2
assert_eq!(line_height(&line, 4), 2);
assert_eq!(line_height(&line, 2), 3);
assert_eq!(line_height(&line, 6), 1);
assert_eq!(word_wrap_line(&line, 4).len(), 2);
assert_eq!(word_wrap_line(&line, 2).len(), 3);
assert_eq!(word_wrap_line(&line, 6).len(), 1);
}
#[test]
fn word_wrap_does_not_split_words_simple_english() {
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let line = Line::from(sample);
// Force small width to exercise wrapping at spaces.
let wrapped = word_wrap_lines(&[line], 40);
let joined: String = wrapped
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
assert!(
!joined.contains("Willowm\nere"),
"should not split inside words:\n{joined}"
);
}
}
+18
View File
@@ -30,7 +30,9 @@ mod chatwidget;
mod citation_regex;
mod cli;
mod colors;
mod common;
pub mod custom_terminal;
mod diff_render;
mod exec_command;
mod file_search;
mod get_git_diff;
@@ -39,15 +41,24 @@ pub mod insert_history;
pub mod live_wrap;
mod log_layer;
mod markdown;
mod markdown_stream;
pub mod onboarding;
mod render;
mod session_log;
mod shimmer;
mod slash_command;
mod status_indicator_widget;
mod streaming;
mod text_block;
mod text_formatting;
mod tui;
mod user_approval_widget;
// Internal vt100-based replay tests live as a separate source file to keep them
// close to the widget code. Include them in unit tests.
#[cfg(test)]
mod chatwidget_stream_tests;
#[cfg(not(debug_assertions))]
mod updates;
#[cfg(not(debug_assertions))]
@@ -55,6 +66,8 @@ use color_eyre::owo_colors::OwoColorize;
pub use cli::Cli;
// (tests access modules directly within the crate)
pub async fn run_main(
cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -265,6 +278,9 @@ fn run_ratatui_app(
let mut terminal = tui::init(&config)?;
terminal.clear()?;
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config);
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
@@ -282,6 +298,8 @@ fn run_ratatui_app(
let usage = app.token_usage();
restore();
// Mark the end of the recorded session.
session_log::log_session_end();
// ignore error when collecting usage – report underlying error instead
app_result.map(|_| usage)
}
-3
View File
@@ -21,9 +21,6 @@ use tracing_subscriber::registry::LookupSpan;
/// Maximum characters forwarded to the TUI. Longer messages are truncated so the
/// single‑line status indicator cannot overflow the viewport.
#[allow(dead_code)]
const _DEFAULT_MAX_LEN: usize = 120;
pub struct TuiLogLayer {
tx: UnboundedSender<String>,
max_len: usize,
+304 -31
View File
@@ -6,7 +6,6 @@ use ratatui::text::Span;
use std::borrow::Cow;
use std::path::Path;
#[allow(dead_code)]
pub(crate) fn append_markdown(
markdown_source: &str,
lines: &mut Vec<Line<'static>>,
@@ -15,42 +14,41 @@ pub(crate) fn append_markdown(
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
}
#[allow(dead_code)]
fn append_markdown_with_opener_and_cwd(
markdown_source: &str,
lines: &mut Vec<Line<'static>>,
file_opener: UriBasedFileOpener,
cwd: &Path,
) {
// Perform citation rewrite *before* feeding the string to the markdown
// renderer. When `file_opener` is absent we bypass the transformation to
// avoid unnecessary allocations.
let processed_markdown = rewrite_file_citations(markdown_source, file_opener, cwd);
let markdown = tui_markdown::from_str(&processed_markdown);
// `tui_markdown` returns a `ratatui::text::Text` where every `Line` borrows
// from the input `message` string. Since the `HistoryCell` stores its lines
// with a `'static` lifetime we must create an **owned** copy of each line
// so that it is no longer tied to `message`. We do this by cloning the
// content of every `Span` into an owned `String`.
for borrowed_line in markdown.lines {
let mut owned_spans = Vec::with_capacity(borrowed_line.spans.len());
for span in &borrowed_line.spans {
// Create a new owned String for the span's content to break the lifetime link.
let owned_span = Span::styled(span.content.to_string(), span.style);
owned_spans.push(owned_span);
// Historically, we fed the entire `markdown_source` into the renderer in
// one pass. However, fenced code blocks sometimes lost leading whitespace
// when formatted by the markdown renderer/highlighter. To preserve code
// block content exactly, split the source into "text" and "code" segments:
// - Render non-code text through `tui_markdown` (with citation rewrite).
// - Render code block content verbatim as plain lines without additional
// formatting, preserving leading spaces.
for seg in split_text_and_fences(markdown_source) {
match seg {
Segment::Text(s) => {
let processed = rewrite_file_citations(&s, file_opener, cwd);
let rendered = tui_markdown::from_str(&processed);
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
Segment::Code { content, .. } => {
// Emit the code content exactly as-is, line by line.
// We don't attempt syntax highlighting to avoid whitespace bugs.
for line in content.split_inclusive('\n') {
// split_inclusive keeps the trailing \n; we want lines without it.
let line = if let Some(stripped) = line.strip_suffix('\n') {
stripped
} else {
line
};
let owned_line: Line<'static> = Line::from(Span::raw(line.to_string()));
lines.push(owned_line);
}
}
}
let owned_line: Line<'static> = Line::from(owned_spans).style(borrowed_line.style);
// Preserve alignment if it was set on the source line.
let owned_line = match borrowed_line.alignment {
Some(alignment) => owned_line.alignment(alignment),
None => owned_line,
};
lines.push(owned_line);
}
}
@@ -61,7 +59,6 @@ fn append_markdown_with_opener_and_cwd(
/// ```text
/// <scheme>://file<ABS_PATH>:<LINE>
/// ```
#[allow(dead_code)]
fn rewrite_file_citations<'a>(
src: &'a str,
file_opener: UriBasedFileOpener,
@@ -101,6 +98,162 @@ fn rewrite_file_citations<'a>(
})
}
// use shared helper from `line_utils`
// Minimal code block splitting.
// - Recognizes fenced blocks opened by ``` or ~~~ (allowing leading whitespace).
// The opening fence may include a language string which we ignore.
// The closing fence must be on its own line (ignoring surrounding whitespace).
// - Additionally recognizes indented code blocks that begin after a blank line
// with a line starting with at least 4 spaces or a tab, and continue for
// consecutive lines that are blank or also indented by >= 4 spaces or a tab.
enum Segment {
Text(String),
Code {
_lang: Option<String>,
content: String,
},
}
fn split_text_and_fences(src: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut curr_text = String::new();
#[derive(Copy, Clone, PartialEq)]
enum CodeMode {
None,
Fenced,
Indented,
}
let mut code_mode = CodeMode::None;
let mut fence_token = "";
let mut code_lang: Option<String> = None;
let mut code_content = String::new();
// We intentionally do not require a preceding blank line for indented code blocks,
// since streamed model output often omits it. This favors preserving indentation.
for line in src.split_inclusive('\n') {
let line_no_nl = line.strip_suffix('\n');
let trimmed_start = match line_no_nl {
Some(l) => l.trim_start(),
None => line.trim_start(),
};
if code_mode == CodeMode::None {
let open = if trimmed_start.starts_with("```") {
Some("```")
} else if trimmed_start.starts_with("~~~") {
Some("~~~")
} else {
None
};
if let Some(tok) = open {
// Flush pending text segment.
if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
curr_text.clear();
}
fence_token = tok;
// Capture language after the token on this line (before newline).
let after = &trimmed_start[tok.len()..];
let lang = after.trim();
code_lang = if lang.is_empty() {
None
} else {
Some(lang.to_string())
};
code_mode = CodeMode::Fenced;
code_content.clear();
// Do not include the opening fence line in output.
continue;
}
// Check for start of an indented code block: only after a blank line
// (or at the beginning), and the line must start with >=4 spaces or a tab.
let raw_line = match line_no_nl {
Some(l) => l,
None => line,
};
let leading_spaces = raw_line.chars().take_while(|c| *c == ' ').count();
let starts_with_tab = raw_line.starts_with('\t');
// Consider any line that begins with >=4 spaces or a tab to start an
// indented code block. This favors preserving indentation even when a
// preceding blank line is omitted (common in streamed model output).
let starts_indented_code = (leading_spaces >= 4) || starts_with_tab;
if starts_indented_code {
// Flush pending text and begin an indented code block.
if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
curr_text.clear();
}
code_mode = CodeMode::Indented;
code_content.clear();
code_content.push_str(line);
// Inside code now; do not treat this line as normal text.
continue;
}
// Normal text line.
curr_text.push_str(line);
} else {
match code_mode {
CodeMode::Fenced => {
// inside fenced code: check for closing fence on its own line
let trimmed = match line_no_nl {
Some(l) => l.trim(),
None => line.trim(),
};
if trimmed == fence_token {
// End code block: emit segment without fences
segments.push(Segment::Code {
_lang: code_lang.take(),
content: code_content.clone(),
});
code_content.clear();
code_mode = CodeMode::None;
fence_token = "";
continue;
}
// Accumulate code content exactly as-is.
code_content.push_str(line);
}
CodeMode::Indented => {
// Continue while the line is blank, or starts with >=4 spaces, or a tab.
let raw_line = match line_no_nl {
Some(l) => l,
None => line,
};
let is_blank = raw_line.trim().is_empty();
let leading_spaces = raw_line.chars().take_while(|c| *c == ' ').count();
let starts_with_tab = raw_line.starts_with('\t');
if is_blank || leading_spaces >= 4 || starts_with_tab {
code_content.push_str(line);
} else {
// Close the indented code block and reprocess this line as normal text.
segments.push(Segment::Code {
_lang: None,
content: code_content.clone(),
});
code_content.clear();
code_mode = CodeMode::None;
// Now handle current line as text.
curr_text.push_str(line);
}
}
CodeMode::None => unreachable!(),
}
}
}
if code_mode != CodeMode::None {
// Unterminated code fence: treat accumulated content as a code segment.
segments.push(Segment::Code {
_lang: code_lang.take(),
content: code_content.clone(),
});
} else if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
@@ -162,4 +315,124 @@ mod tests {
// Ensure helper rewrites.
assert_ne!(markdown, unchanged);
}
#[test]
fn fenced_code_blocks_preserve_leading_whitespace() {
let src = "```\n indented\n\t\twith tabs\n four spaces\n```\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(
rendered,
vec![
" indented".to_string(),
"\t\twith tabs".to_string(),
" four spaces".to_string()
]
);
}
#[test]
fn citations_not_rewritten_inside_code_blocks() {
let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
// Expect first and last lines rewritten, middle line unchanged.
assert!(rendered[0].contains("vscode://file"));
assert_eq!(rendered[1], "Inside 【F:/x.rs†L2】");
assert!(matches!(rendered.last(), Some(s) if s.contains("vscode://file")));
}
#[test]
fn indented_code_blocks_preserve_leading_whitespace() {
let src = "Before\n code 1\n\tcode with tab\n code 2\nAfter\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(
rendered,
vec![
"Before".to_string(),
" code 1".to_string(),
"\tcode with tab".to_string(),
" code 2".to_string(),
"After".to_string()
]
);
}
#[test]
fn citations_not_rewritten_inside_indented_code_blocks() {
let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
// Expect first and last lines rewritten, and the indented code line present
// unchanged (citations inside not rewritten). We do not assert on blank
// separator lines since the markdown renderer may normalize them.
assert!(rendered.iter().any(|s| s.contains("vscode://file")));
assert!(rendered.iter().any(|s| s == " Inside 【F:/x.rs†L2】"));
}
#[test]
fn append_markdown_preserves_full_text_line() {
use codex_core::config_types::UriBasedFileOpener;
use std::path::Path;
let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
assert_eq!(
out.len(),
1,
"expected a single rendered line for plain text"
);
let rendered: String = out
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("");
assert_eq!(
rendered,
"Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?"
);
}
}
+549
View File
@@ -0,0 +1,549 @@
use std::collections::VecDeque;
use codex_core::config::Config;
use ratatui::text::Line;
use crate::markdown;
use crate::render::markdown_utils::is_inside_unclosed_fence;
use crate::render::markdown_utils::strip_empty_fenced_code_blocks;
/// Newline-gated accumulator that renders markdown and commits only fully
/// completed logical lines.
pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_line_count: usize,
}
impl MarkdownStreamCollector {
pub fn new() -> Self {
Self {
buffer: String::new(),
committed_line_count: 0,
}
}
/// Returns the number of logical lines that have already been committed
/// (i.e., previously returned from `commit_complete_lines`).
pub fn committed_count(&self) -> usize {
self.committed_line_count
}
pub fn clear(&mut self) {
self.buffer.clear();
self.committed_line_count = 0;
}
/// Replace the buffered content and mark that the first `committed_count`
/// logical lines are already committed.
pub fn replace_with_and_mark_committed(&mut self, s: &str, committed_count: usize) {
self.buffer.clear();
self.buffer.push_str(s);
self.committed_line_count = committed_count;
}
pub fn push_delta(&mut self, delta: &str) {
self.buffer.push_str(delta);
}
/// Insert a paragraph/section separator if one is not already present at the
/// end of the buffer. Ensures the next content starts after a blank line.
pub fn insert_section_break(&mut self) {
if self.buffer.is_empty() {
return;
}
if self.buffer.ends_with("\n\n") {
return;
}
if self.buffer.ends_with('\n') {
self.buffer.push('\n');
} else {
self.buffer.push_str("\n\n");
}
}
/// Render the full buffer and return only the newly completed logical lines
/// since the last commit. When the buffer does not end with a newline, the
/// final rendered line is considered incomplete and is not emitted.
pub fn commit_complete_lines(&mut self, config: &Config) -> Vec<Line<'static>> {
// In non-test builds, unwrap an outer ```markdown fence during commit as well,
// so fence markers never appear in streamed history.
let source = unwrap_markdown_language_fence_if_enabled(self.buffer.clone());
let source = strip_empty_fenced_code_blocks(&source);
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
let mut complete_line_count = rendered.len();
if complete_line_count > 0
&& crate::render::line_utils::is_blank_line_spaces_only(
&rendered[complete_line_count - 1],
)
{
complete_line_count -= 1;
}
if !self.buffer.ends_with('\n') {
complete_line_count = complete_line_count.saturating_sub(1);
// If we're inside an unclosed fenced code block, also drop the
// last rendered line to avoid committing a partial code line.
if is_inside_unclosed_fence(&source) {
complete_line_count = complete_line_count.saturating_sub(1);
}
}
if self.committed_line_count >= complete_line_count {
return Vec::new();
}
let out_slice = &rendered[self.committed_line_count..complete_line_count];
// Strong correctness: while a fenced code block is open (no closing fence yet),
// do not emit any new lines from inside it. Wait until the fence closes to emit
// the entire block together. This avoids stray backticks and misformatted content.
if is_inside_unclosed_fence(&source) {
return Vec::new();
}
let out = out_slice.to_vec();
self.committed_line_count = complete_line_count;
out
}
/// Finalize the stream: emit all remaining lines beyond the last commit.
/// If the buffer does not end with a newline, a temporary one is appended
/// for rendering. Optionally unwraps ```markdown language fences in
/// non-test builds.
pub fn finalize_and_drain(&mut self, config: &Config) -> Vec<Line<'static>> {
let mut source: String = self.buffer.clone();
if !source.ends_with('\n') {
source.push('\n');
}
let source = unwrap_markdown_language_fence_if_enabled(source);
let source = strip_empty_fenced_code_blocks(&source);
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
} else {
rendered[self.committed_line_count..].to_vec()
};
// Reset collector state for next stream.
self.clear();
out
}
}
/// fence helpers are provided by `crate::render::markdown_utils`
#[cfg(test)]
fn unwrap_markdown_language_fence_if_enabled(s: String) -> String {
// In tests, keep content exactly as provided to simplify assertions.
s
}
#[cfg(not(test))]
fn unwrap_markdown_language_fence_if_enabled(s: String) -> String {
// Best-effort unwrap of a single outer fenced markdown block.
// Recognizes common forms like ```markdown, ```md (any case), optional
// surrounding whitespace, and flexible trailing newlines/CRLF.
// If the block is not recognized, return the input unchanged.
let lines = s.lines().collect::<Vec<_>>();
if lines.len() < 2 {
return s;
}
// Identify opening fence and language.
let open = lines.first().map(|l| l.trim_start()).unwrap_or("");
if !open.starts_with("```") {
return s;
}
let lang = open.trim_start_matches("```").trim();
let is_markdown_lang = lang.eq_ignore_ascii_case("markdown") || lang.eq_ignore_ascii_case("md");
if !is_markdown_lang {
return s;
}
// Find the last non-empty line and ensure it is a closing fence.
let mut last_idx = lines.len() - 1;
while last_idx > 0 && lines[last_idx].trim().is_empty() {
last_idx -= 1;
}
if lines[last_idx].trim() != "```" {
return s;
}
// Reconstruct the inner content between the fences.
let mut out = String::new();
for l in lines.iter().take(last_idx).skip(1) {
out.push_str(l);
out.push('\n');
}
out
}
pub(crate) struct StepResult {
pub history: Vec<Line<'static>>, // lines to insert into history this step
}
/// Streams already-rendered rows into history while computing the newest K
/// rows to show in a live overlay.
pub(crate) struct AnimatedLineStreamer {
queue: VecDeque<Line<'static>>,
}
impl AnimatedLineStreamer {
pub fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
pub fn clear(&mut self) {
self.queue.clear();
}
pub fn enqueue(&mut self, lines: Vec<Line<'static>>) {
for l in lines {
self.queue.push_back(l);
}
}
pub fn step(&mut self) -> StepResult {
let mut history = Vec::new();
// Move exactly one per tick to animate gradual insertion.
let burst = if self.queue.is_empty() { 0 } else { 1 };
for _ in 0..burst {
if let Some(l) = self.queue.pop_front() {
history.push(l);
}
}
StepResult { history }
}
pub fn drain_all(&mut self) -> StepResult {
let mut history = Vec::new();
while let Some(l) = self.queue.pop_front() {
history.push(l);
}
StepResult { history }
}
pub fn is_idle(&self) -> bool {
self.queue.is_empty()
}
}
#[cfg(test)]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
config: &Config,
) -> Vec<Line<'static>> {
let mut collector = MarkdownStreamCollector::new();
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
if d.contains('\n') {
out.extend(collector.commit_complete_lines(config));
}
}
if finalize {
out.extend(collector.finalize_and_drain(config));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
fn test_config() -> Config {
let overrides = ConfigOverrides {
cwd: std::env::current_dir().ok(),
..Default::default()
};
match Config::load_with_cli_overrides(vec![], overrides) {
Ok(c) => c,
Err(e) => panic!("load test config: {e}"),
}
}
#[test]
fn no_commit_until_newline() {
let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new();
c.push_delta("Hello, world");
let out = c.commit_complete_lines(&cfg);
assert!(out.is_empty(), "should not commit without newline");
c.push_delta("!\n");
let out2 = c.commit_complete_lines(&cfg);
assert_eq!(out2.len(), 1, "one completed line after newline");
}
#[test]
fn finalize_commits_partial_line() {
let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new();
c.push_delta("Line without newline");
let out = c.finalize_and_drain(&cfg);
assert_eq!(out.len(), 1);
}
#[test]
fn heading_starts_on_new_line_when_following_paragraph() {
let cfg = test_config();
// Stream a paragraph line, then a heading on the next line.
// Expect two distinct rendered lines: "Hello." and "Heading".
let mut c = super::MarkdownStreamCollector::new();
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
out1.len(),
1,
"first commit should contain only the paragraph line, got {}: {:?}",
out1.len(),
s1
);
c.push_delta("## Heading\n");
let out2 = c.commit_complete_lines(&cfg);
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["", "## Heading"],
"expected a blank separator then the heading line"
);
let line_to_string = |l: &ratatui::text::Line<'_>| -> String {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
};
assert_eq!(line_to_string(&out1[0]), "Hello.");
assert_eq!(line_to_string(&out2[1]), "## Heading");
}
#[test]
fn heading_not_inlined_when_split_across_chunks() {
let cfg = test_config();
// Paragraph without trailing newline, then a chunk that starts with the newline
// and the heading text, then a final newline. The collector should first commit
// only the paragraph line, and later commit the heading as its own line.
let mut c = super::MarkdownStreamCollector::new();
c.push_delta("Sounds good!");
// No commit yet
assert!(c.commit_complete_lines(&cfg).is_empty());
// Introduce the newline that completes the paragraph and the start of the heading.
c.push_delta("\n## Adding Bird subcommand");
let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s1,
vec!["Sounds good!", ""],
"expected paragraph followed by blank separator before heading chunk"
);
// Now finish the heading line with the trailing newline.
c.push_delta("\n");
let out2 = c.commit_complete_lines(&cfg);
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["## Adding Bird subcommand"],
"expected the heading line only on the final commit"
);
// Sanity check raw markdown rendering for a simple line does not produce spurious extras.
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("Hello.\n", &mut rendered, &cfg);
let rendered_strings: Vec<String> = rendered
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
rendered_strings,
vec!["Hello."],
"unexpected markdown lines: {rendered_strings:?}"
);
let line_to_string = |l: &ratatui::text::Line<'_>| -> String {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
};
assert_eq!(line_to_string(&out1[0]), "Sounds good!");
assert_eq!(line_to_string(&out1[1]), "");
assert_eq!(line_to_string(&out2[0]), "## Adding Bird subcommand");
}
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect()
}
#[test]
fn lists_and_fences_commit_without_duplication() {
let cfg = test_config();
// List case
let deltas = vec!["- a\n- ", "b\n- c\n"];
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("- a\n- b\n- c\n", &mut rendered_all, &cfg);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_str, rendered_all_str,
"list streaming should equal full render without duplication"
);
// Fenced code case: stream in small chunks
let deltas2 = vec!["```", "\nco", "de 1\ncode 2\n", "```\n"];
let streamed2 = simulate_stream_markdown_for_tests(&deltas2, true, &cfg);
let streamed2_str = lines_to_plain_strings(&streamed2);
let mut rendered_all2: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("```\ncode 1\ncode 2\n```\n", &mut rendered_all2, &cfg);
let rendered_all2_str = lines_to_plain_strings(&rendered_all2);
assert_eq!(
streamed2_str, rendered_all2_str,
"fence streaming should equal full render without duplication"
);
}
#[test]
fn utf8_boundary_safety_and_wide_chars() {
let cfg = test_config();
// Emoji (wide), CJK, control char, digit + combining macron sequences
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
let deltas = vec![
"🙂",
"🙂",
"🙂\n汉",
"字漢",
"字\nA",
"\u{0003}",
"0",
"\u{0304}",
"\n",
];
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(input, &mut rendered_all, &cfg);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_str, rendered_all_str,
"utf8/wide-char streaming should equal full render without duplication or truncation"
);
}
#[test]
fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
let cfg = test_config();
// An empty fenced code block followed by a heading should not render the fence,
// but should preserve a blank separator line so the heading starts on a new line.
let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let texts = lines_to_plain_strings(&streamed);
assert!(
texts.iter().all(|s| !s.contains("```")),
"no fence markers expected: {texts:?}"
);
// Expect the heading and no fence markers. A blank separator may or may not be rendered at start.
assert!(
texts.iter().any(|s| s == "## Heading"),
"expected heading line: {texts:?}"
);
}
#[test]
fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
let cfg = test_config();
let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let texts = lines_to_plain_strings(&streamed);
let para_idx = match texts.iter().position(|s| s == "Para.") {
Some(i) => i,
None => panic!("para present"),
};
let head_idx = match texts.iter().position(|s| s == "## Title") {
Some(i) => i,
None => panic!("heading present"),
};
assert!(
head_idx > para_idx,
"heading should not merge with paragraph: {texts:?}"
);
}
}
+45
View File
@@ -0,0 +1,45 @@
use ratatui::text::Line;
use ratatui::text::Span;
/// Clone a borrowed ratatui `Line` into an owned `'static` line.
pub fn line_to_static(line: &Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
/// Append owned copies of borrowed lines to `out`.
pub fn push_owned_lines<'a>(src: &[Line<'a>], out: &mut Vec<Line<'static>>) {
for l in src {
out.push(line_to_static(l));
}
}
/// Consider a line blank if it has no spans or only spans whose contents are
/// empty or consist solely of spaces (no tabs/newlines).
pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans
.iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
}
/// Consider a line blank if its spans are empty or all span contents are
/// whitespace when trimmed.
pub fn is_blank_line_trim(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans.iter().all(|s| s.content.trim().is_empty())
}
+72
View File
@@ -0,0 +1,72 @@
/// Returns true if the provided text contains an unclosed fenced code block
/// (opened by ``` or ~~~, closed by a matching fence on its own line).
pub fn is_inside_unclosed_fence(s: &str) -> bool {
let mut open = false;
for line in s.lines() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
if !open {
open = true;
} else {
// closing fence on same pattern toggles off
open = false;
}
}
}
open
}
/// Remove fenced code blocks that contain no content (whitespace-only) to avoid
/// streaming empty code blocks like ```lang\n``` or ```\n```.
pub fn strip_empty_fenced_code_blocks(s: &str) -> String {
// Only remove complete fenced blocks that contain no non-whitespace content.
// Leave all other content unchanged to avoid affecting partial streams.
let lines: Vec<&str> = s.lines().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0usize;
while i < lines.len() {
let line = lines[i];
let trimmed_start = line.trim_start();
let fence_token = if trimmed_start.starts_with("```") {
"```"
} else if trimmed_start.starts_with("~~~") {
"~~~"
} else {
""
};
if !fence_token.is_empty() {
// Find a matching closing fence on its own line.
let mut j = i + 1;
let mut has_content = false;
let mut found_close = false;
while j < lines.len() {
let l = lines[j];
if l.trim() == fence_token {
found_close = true;
break;
}
if !l.trim().is_empty() {
has_content = true;
}
j += 1;
}
if found_close && !has_content {
// Drop i..=j and insert at most a single blank separator line.
if !out.ends_with('\n') {
out.push('\n');
}
i = j + 1;
continue;
}
// Not an empty fenced block; emit as-is.
out.push_str(line);
out.push('\n');
i += 1;
} else {
out.push_str(line);
out.push('\n');
i += 1;
}
}
out
}
+2
View File
@@ -0,0 +1,2 @@
pub mod line_utils;
pub mod markdown_utils;
+243
View File
@@ -0,0 +1,243 @@
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use codex_core::config::Config;
use codex_core::protocol::Op;
use once_cell::sync::Lazy;
use once_cell::sync::OnceCell;
use serde::Serialize;
use serde_json::json;
use crate::app_event::AppEvent;
static LOGGER: Lazy<SessionLogger> = Lazy::new(SessionLogger::new);
struct SessionLogger {
file: OnceCell<Mutex<File>>,
}
impl SessionLogger {
fn new() -> Self {
Self {
file: OnceCell::new(),
}
}
fn open(&self, path: PathBuf) -> std::io::Result<()> {
let mut opts = OpenOptions::new();
opts.create(true).truncate(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = opts.open(path)?;
// If already initialized, ignore and succeed.
if self.file.get().is_some() {
return Ok(());
}
let _ = self.file.set(Mutex::new(file));
Ok(())
}
fn write_json_line(&self, value: serde_json::Value) {
let Some(mutex) = self.file.get() else {
return;
};
let mut guard = match mutex.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
match serde_json::to_string(&value) {
Ok(serialized) => {
if let Err(e) = guard.write_all(serialized.as_bytes()) {
tracing::warn!("session log write error: {}", e);
return;
}
if let Err(e) = guard.write_all(b"\n") {
tracing::warn!("session log write error: {}", e);
return;
}
if let Err(e) = guard.flush() {
tracing::warn!("session log flush error: {}", e);
}
}
Err(e) => tracing::warn!("session log serialize error: {}", e),
}
}
fn is_enabled(&self) -> bool {
self.file.get().is_some()
}
}
fn now_ts() -> String {
// RFC3339 for readability; consumers can parse as needed.
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
pub(crate) fn maybe_init(config: &Config) {
let enabled = std::env::var("CODEX_TUI_RECORD_SESSION")
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false);
if !enabled {
return;
}
let path = if let Ok(path) = std::env::var("CODEX_TUI_SESSION_LOG_PATH") {
PathBuf::from(path)
} else {
let mut p = match codex_core::config::log_dir(config) {
Ok(dir) => dir,
Err(_) => std::env::temp_dir(),
};
let filename = format!(
"session-{}.jsonl",
chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
);
p.push(filename);
p
};
if let Err(e) = LOGGER.open(path.clone()) {
tracing::error!("failed to open session log {:?}: {}", path, e);
return;
}
// Write a header record so we can attach context.
let header = json!({
"ts": now_ts(),
"dir": "meta",
"kind": "session_start",
"cwd": config.cwd,
"model": config.model,
"model_provider_id": config.model_provider_id,
"model_provider_name": config.model_provider.name,
});
LOGGER.write_json_line(header);
}
pub(crate) fn log_inbound_app_event(event: &AppEvent) {
// Log only if enabled
if !LOGGER.is_enabled() {
return;
}
match event {
AppEvent::CodexEvent(ev) => {
write_record("to_tui", "codex_event", ev);
}
AppEvent::KeyEvent(k) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "key_event",
"event": format!("{:?}", k),
});
LOGGER.write_json_line(value);
}
AppEvent::Paste(s) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "paste",
"text": s,
});
LOGGER.write_json_line(value);
}
AppEvent::DispatchCommand(cmd) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "slash_command",
"command": format!("{:?}", cmd),
});
LOGGER.write_json_line(value);
}
// Internal UI events; still log for fidelity, but avoid heavy payloads.
AppEvent::InsertHistory(lines) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history",
"lines": lines.len(),
});
LOGGER.write_json_line(value);
}
AppEvent::StartFileSearch(query) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "file_search_start",
"query": query,
});
LOGGER.write_json_line(value);
}
AppEvent::FileSearchResult { query, matches } => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "file_search_result",
"query": query,
"matches": matches.len(),
});
LOGGER.write_json_line(value);
}
AppEvent::LatestLog(line) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "log_line",
"line": line,
});
LOGGER.write_json_line(value);
}
// Noise or control flow – record variant only
other => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "app_event",
"variant": format!("{other:?}").split('(').next().unwrap_or("app_event"),
});
LOGGER.write_json_line(value);
}
}
}
pub(crate) fn log_outbound_op(op: &Op) {
if !LOGGER.is_enabled() {
return;
}
write_record("from_tui", "op", op);
}
pub(crate) fn log_session_end() {
if !LOGGER.is_enabled() {
return;
}
let value = json!({
"ts": now_ts(),
"dir": "meta",
"kind": "session_end",
});
LOGGER.write_json_line(value);
}
fn write_record<T>(dir: &str, kind: &str, obj: &T)
where
T: Serialize,
{
let value = json!({
"ts": now_ts(),
"dir": dir,
"kind": kind,
"payload": obj,
});
LOGGER.write_json_line(value);
}
+2
View File
@@ -16,6 +16,7 @@ pub enum SlashCommand {
Init,
Compact,
Diff,
Mention,
Status,
Prompts,
Logout,
@@ -33,6 +34,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Logout => "log out of Codex",
@@ -0,0 +1,14 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"proposed patch to 1 file (+2 -0) "
" ⎿ README.md (+2 -0) "
" 1 +first line "
" 2 +second line "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,16 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"proposed patch to 1 file (+1 -1) "
" ⎿ src/lib.rs → src/lib_new.rs (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,12 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
" 1 +this is a very long line that should wrap across multiple terminal col "
" umns and continue "
" "
" "
" "
" "
" "
" "
+7 -6
View File
@@ -9,6 +9,7 @@ use std::thread;
use std::time::Duration;
use std::time::Instant;
use codex_core::protocol::Op;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -44,11 +45,7 @@ pub(crate) struct StatusIndicatorWidget {
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
start_time: Instant,
// Keep one sender alive to prevent the channel from closing while the
// animation thread is still running. The field itself is currently not
// accessed anywhere, therefore the leading underscore silences the
// `dead_code` warning without affecting behavior.
_app_event_tx: AppEventSender,
app_event_tx: AppEventSender,
}
impl StatusIndicatorWidget {
@@ -82,7 +79,7 @@ impl StatusIndicatorWidget {
running,
start_time: Instant::now(),
_app_event_tx: app_event_tx,
app_event_tx,
}
}
@@ -120,6 +117,10 @@ impl StatusIndicatorWidget {
self.reveal_len_at_base = shown_now.min(new_len);
}
pub(crate) fn interrupt(&self) {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
+281
View File
@@ -0,0 +1,281 @@
use codex_core::config::Config;
use ratatui::text::Line;
use super::HeaderEmitter;
use super::StreamKind;
use super::StreamState;
/// Sink for history insertions and animation control.
pub(crate) trait HistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>);
fn start_commit_animation(&self);
fn stop_commit_animation(&self);
}
/// Concrete sink backed by `AppEventSender`.
pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEventSender);
impl HistorySink for AppEventHistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>) {
self.0
.send(crate::app_event::AppEvent::InsertHistory(lines))
}
fn start_commit_animation(&self) {
self.0
.send(crate::app_event::AppEvent::StartCommitAnimation)
}
fn stop_commit_animation(&self) {
self.0.send(crate::app_event::AppEvent::StopCommitAnimation)
}
}
type Lines = Vec<Line<'static>>;
/// Controller that manages newline-gated streaming, header emission, and
/// commit animation across streams.
pub(crate) struct StreamController {
config: Config,
header: HeaderEmitter,
states: [StreamState; 2],
current_stream: Option<StreamKind>,
finishing_after_drain: bool,
}
impl StreamController {
pub(crate) fn new(config: Config) -> Self {
Self {
config,
header: HeaderEmitter::new(),
states: [StreamState::new(), StreamState::new()],
current_stream: None,
finishing_after_drain: false,
}
}
pub(crate) fn reset_headers_for_new_turn(&mut self) {
self.header.reset_for_new_turn();
}
pub(crate) fn is_write_cycle_active(&self) -> bool {
self.current_stream.is_some()
}
pub(crate) fn clear_all(&mut self) {
self.states.iter_mut().for_each(|s| s.clear());
self.current_stream = None;
self.finishing_after_drain = false;
// leave header state unchanged; caller decides when to reset
}
#[inline]
fn idx(kind: StreamKind) -> usize {
kind as usize
}
fn state(&self, kind: StreamKind) -> &StreamState {
&self.states[Self::idx(kind)]
}
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
&mut self.states[Self::idx(kind)]
}
fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool {
self.header.maybe_emit(kind, out_lines)
}
#[inline]
fn ensure_single_trailing_blank(lines: &mut Lines) {
if lines
.last()
.map(|l| !crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(true)
{
lines.push(Line::from(""));
}
}
/// Begin a stream, flushing previously completed lines from any other
/// active stream to maintain ordering.
pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) {
if let Some(current) = self.current_stream {
if current != kind {
// Synchronously flush completed lines from previous stream.
let cfg = self.config.clone();
let prev_state = self.state_mut(current);
let newly_completed = prev_state.collector.commit_complete_lines(&cfg);
if !newly_completed.is_empty() {
prev_state.enqueue(newly_completed);
}
let step = prev_state.drain_all();
if !step.history.is_empty() {
let mut lines: Lines = Vec::new();
self.emit_header_if_needed(current, &mut lines);
lines.extend(step.history);
// Ensure at most one trailing blank after the flushed block.
Self::ensure_single_trailing_blank(&mut lines);
sink.insert_history(lines);
}
self.current_stream = None;
}
}
if self.current_stream != Some(kind) {
let prev = self.current_stream;
self.current_stream = Some(kind);
// Starting a new stream cancels any pending finish-from-previous-stream animation.
self.finishing_after_drain = false;
if prev.is_some() {
self.header.reset_for_stream(kind);
}
// Emit header immediately for reasoning; for answers, defer to first commit.
if matches!(kind, StreamKind::Reasoning) {
let mut header_lines = Vec::new();
if self.emit_header_if_needed(kind, &mut header_lines) {
sink.insert_history(header_lines);
}
}
}
}
/// Push a delta; if it contains a newline, commit completed lines and start animation.
pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) {
let Some(kind) = self.current_stream else {
return;
};
let cfg = self.config.clone();
let state = self.state_mut(kind);
state.collector.push_delta(delta);
if delta.contains('\n') {
let newly_completed = state.collector.commit_complete_lines(&cfg);
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
sink.start_commit_animation();
}
}
}
/// Insert a reasoning section break and commit any newly completed lines.
pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) {
if self.current_stream != Some(StreamKind::Reasoning) {
self.begin(StreamKind::Reasoning, sink);
}
let cfg = self.config.clone();
let state = self.state_mut(StreamKind::Reasoning);
state.collector.insert_section_break();
let newly_completed = state.collector.commit_complete_lines(&cfg);
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
sink.start_commit_animation();
}
}
/// Finalize the active stream. If `flush_immediately` is true, drain and emit now.
pub(crate) fn finalize(
&mut self,
kind: StreamKind,
flush_immediately: bool,
sink: &impl HistorySink,
) -> bool {
if self.current_stream != Some(kind) {
return false;
}
let cfg = self.config.clone();
// Finalize collector first.
let remaining = {
let state = self.state_mut(kind);
state.collector.finalize_and_drain(&cfg)
};
if flush_immediately {
// Collect all output first to avoid emitting headers when there is no content.
let mut out_lines: Lines = Vec::new();
{
let state = self.state_mut(kind);
if !remaining.is_empty() {
state.enqueue(remaining);
}
let step = state.drain_all();
out_lines.extend(step.history);
}
if !out_lines.is_empty() {
let mut lines_with_header: Lines = Vec::new();
self.emit_header_if_needed(kind, &mut lines_with_header);
lines_with_header.extend(out_lines);
Self::ensure_single_trailing_blank(&mut lines_with_header);
sink.insert_history(lines_with_header);
}
// Cleanup
self.state_mut(kind).clear();
// Allow a subsequent block of the same kind in this turn to emit its header.
self.header.allow_reemit_for_same_kind_in_turn(kind);
// Also clear the per-stream emitted flag so the header can render again.
self.header.reset_for_stream(kind);
self.current_stream = None;
self.finishing_after_drain = false;
true
} else {
if !remaining.is_empty() {
let state = self.state_mut(kind);
state.enqueue(remaining);
}
// Spacer animated out
self.state_mut(kind).enqueue(vec![Line::from("")]);
self.finishing_after_drain = true;
sink.start_commit_animation();
false
}
}
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool {
let Some(kind) = self.current_stream else {
return false;
};
let step = {
let state = self.state_mut(kind);
state.step()
};
if !step.history.is_empty() {
let mut lines: Lines = Vec::new();
self.emit_header_if_needed(kind, &mut lines);
let mut out = lines;
out.extend(step.history);
sink.insert_history(out);
}
let is_idle = self.state(kind).is_idle();
if is_idle {
sink.stop_commit_animation();
if self.finishing_after_drain {
// Reset and notify
self.state_mut(kind).clear();
// Allow a subsequent block of the same kind in this turn to emit its header.
self.header.allow_reemit_for_same_kind_in_turn(kind);
// Also clear the per-stream emitted flag so the header can render again.
self.header.reset_for_stream(kind);
self.current_stream = None;
self.finishing_after_drain = false;
return true;
}
}
false
}
/// Apply a full final answer: replace queued content with only the remaining tail,
/// then finalize immediately and notify completion.
pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool {
self.begin(StreamKind::Answer, sink);
if !message.is_empty() {
let mut msg_with_nl = message.to_string();
if !msg_with_nl.ends_with('\n') {
msg_with_nl.push('\n');
}
let state = self.state_mut(StreamKind::Answer);
let already_committed = state.collector.committed_count();
// Preserve previously committed count so finalize emits only the remaining tail.
state
.collector
.replace_with_and_mark_committed(&msg_with_nl, already_committed);
}
self.finalize(StreamKind::Answer, true, sink)
}
}
+130
View File
@@ -0,0 +1,130 @@
use crate::markdown_stream::AnimatedLineStreamer;
use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod controller;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StreamKind {
Answer,
Reasoning,
}
pub(crate) struct StreamState {
pub(crate) collector: MarkdownStreamCollector,
pub(crate) streamer: AnimatedLineStreamer,
}
impl StreamState {
pub(crate) fn new() -> Self {
Self {
collector: MarkdownStreamCollector::new(),
streamer: AnimatedLineStreamer::new(),
}
}
pub(crate) fn clear(&mut self) {
self.collector.clear();
self.streamer.clear();
}
pub(crate) fn step(&mut self) -> crate::markdown_stream::StepResult {
self.streamer.step()
}
pub(crate) fn drain_all(&mut self) -> crate::markdown_stream::StepResult {
self.streamer.drain_all()
}
pub(crate) fn is_idle(&self) -> bool {
self.streamer.is_idle()
}
pub(crate) fn enqueue(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
self.streamer.enqueue(lines)
}
}
pub(crate) struct HeaderEmitter {
reasoning_emitted_this_turn: bool,
answer_emitted_this_turn: bool,
reasoning_emitted_in_stream: bool,
answer_emitted_in_stream: bool,
}
impl HeaderEmitter {
pub(crate) fn new() -> Self {
Self {
reasoning_emitted_this_turn: false,
answer_emitted_this_turn: false,
reasoning_emitted_in_stream: false,
answer_emitted_in_stream: false,
}
}
pub(crate) fn reset_for_new_turn(&mut self) {
self.reasoning_emitted_this_turn = false;
self.answer_emitted_this_turn = false;
self.reasoning_emitted_in_stream = false;
self.answer_emitted_in_stream = false;
}
pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_in_stream = false,
StreamKind::Answer => self.answer_emitted_in_stream = false,
}
}
pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_in_stream,
StreamKind::Answer => self.answer_emitted_in_stream,
}
}
/// Allow emitting the header again for the same kind within the current turn.
///
/// This is used when a stream (e.g., Answer) is finalized and a subsequent
/// block of the same kind is started within the same turn. Without this,
/// only the first block would render a header.
pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_this_turn = false,
StreamKind::Answer => self.answer_emitted_this_turn = false,
}
}
pub(crate) fn maybe_emit(
&mut self,
kind: StreamKind,
out_lines: &mut Vec<ratatui::text::Line<'static>>,
) -> bool {
let already_emitted_this_turn = match kind {
StreamKind::Reasoning => self.reasoning_emitted_this_turn,
StreamKind::Answer => self.answer_emitted_this_turn,
};
let already_emitted_in_stream = self.has_emitted_for_stream(kind);
if !already_emitted_in_stream && !already_emitted_this_turn {
out_lines.push(render_header_line(kind));
match kind {
StreamKind::Reasoning => {
self.reasoning_emitted_in_stream = true;
self.reasoning_emitted_this_turn = true;
// Reset opposite header so it may be emitted again this turn
self.answer_emitted_this_turn = false;
}
StreamKind::Answer => {
self.answer_emitted_in_stream = true;
self.answer_emitted_this_turn = true;
// Reset opposite header so it may be emitted again this turn
self.reasoning_emitted_this_turn = false;
}
}
true
} else {
false
}
}
}
fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
use ratatui::style::Stylize;
match kind {
StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()),
StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()),
}
}
+2 -7
View File
@@ -9,6 +9,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::user_agent::get_codex_user_agent;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
@@ -67,13 +68,7 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
tag_name: latest_tag_name,
} = reqwest::Client::new()
.get(LATEST_RELEASE_URL)
.header(
"User-Agent",
format!(
"codex/{} (+https://github.com/openai/codex)",
env!("CARGO_PKG_VERSION")
),
)
.header("User-Agent", get_codex_user_agent(None))
.send()
.await?
.error_for_status()?
+2 -7
View File
@@ -35,8 +35,6 @@ pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
#[allow(dead_code)]
cwd: PathBuf,
reason: Option<String>,
},
ApplyPatch {
@@ -247,7 +245,7 @@ impl UserApprovalWidget<'_> {
match decision {
ReviewDecision::Approved => {
lines.push(Line::from(vec![
"✓ ".fg(Color::Green),
"✔ ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -258,7 +256,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::ApprovedForSession => {
lines.push(Line::from(vec![
"✓ ".fg(Color::Green),
"✔ ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -394,7 +392,6 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::path::PathBuf;
use std::sync::mpsc::channel;
#[test]
@@ -404,7 +401,6 @@ mod tests {
let req = ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".to_string()],
cwd: PathBuf::new(),
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
@@ -427,7 +423,6 @@ mod tests {
let req = ApprovalRequest::Exec {
id: "2".to_string(),
command: vec!["echo".to_string()],
cwd: PathBuf::new(),
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
File diff suppressed because one or more lines are too long
+298
View File
@@ -0,0 +1,298 @@
thinking
Investigating binary sizes
I need to check the codex-rs repository to explain why the project's binaries
are large. The user is likely seeking specifics about the setup: are Rust builds
static, what features are enabled, and is debug information included? It could
be due to static linking, included OpenSSL, or how panic handling is set up. I
should look into the Cargo.toml file to confirm features and profiles without
needing to edit any code. Let's get started on this!
codex
I’m going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then I’ll summarize the main causes.
⚡ Running ls -la
⎿ total 6696
drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 .
drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 ..
-rw-r--r--@ 1 easong staff 10244 Aug 8 21:13 .DS_Store
-rw-r--r--@ 1 easong staff 212 Aug 4 18:49 .gitignore
… +30 lines
-rw-r--r--@ 1 easong staff 9971 Aug 8 23:07 simpler.md
drwxr-xr-x@ 6 easong staff 192 Aug 4 22:40 target
drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui
-rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md
-rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md
⚡ Running sed -n '1,200p' Cargo.toml
⎿ [workspace]
members = [
"ansi-escape",
"apply-patch",
"arg0",
… +38 lines
codegen-units = 1
[patch.crates-io]
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
"nornagon-v0.29.0-patch" }
Optimizing Rust Release Profile
I'm reviewing the workspace's release profile, which has settings like lto=fat,
strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that
"strip = symbols" only removes symbols, not debug information. In Rust 1.73+,
both "symbols" and "debuginfo" stripping options are available. I should check
whether using these results in a smaller binary. I’ll also skim through crates
for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox,
tui, login, ollama, and mcp.
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
⎿ --- ansi-escape/Cargo.toml
[package]
edition = "2024"
name = "codex-ansi-escape"
version = { workspace = true }
… +237 lines
"process",
"rt-multi-thread",
"signal",
] }
tracing = { version
⚡ Running sed -n '1,200p' execpolicy/Cargo.toml
⎿ [package]
name = "codex-execpolicy"
version = { workspace = true }
edition = "2024"
… +23 lines
serde_json = "1.0.142"
serde_with = { version = "3", features = ["macros"] }
[dev-dependencies]
tempfile = "3.13.0"
⚡ Running sed -n '1,200p' file-search/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-file-search"
version = { workspace = true }
… +11 lines
ignore = "0.4.23"
nucleo-matcher = "0.3.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1", features = ["full"] }
⚡ Running sed -n '1,200p' linux-sandbox/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-linux-sandbox"
version = { workspace = true }
… +24 lines
"macros",
"process",
"rt-multi-thread",
"signal",
] }
⚡ Running sed -n '1,200p' login/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-login"
version = { workspace = true }
… +17 lines
] }
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"
⚡ Running sed -n '1,200p' mcp-client/Cargo.toml
⎿ [package]
name = "codex-mcp-client"
version = { workspace = true }
edition = "2024"
… +13 lines
"process",
"rt-multi-thread",
"sync",
"time",
] }
⚡ Running sed -n '1,200p' mcp-server/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-mcp-server"
version = { workspace = true }
… +35 lines
mcp_test_support = { path = "tests/common" }
pretty_assertions = "1.4.1"
tempfile = "3"
tokio-test = "0.4"
wiremock = "0.6"
⚡ Running sed -n '1,200p' mcp-types/Cargo.toml
⎿ [package]
edition = "2024"
name = "mcp-types"
version = { workspace = true }
… +1 lines
workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
⚡ Running sed -n '1,200p' ollama/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-ollama"
version = { workspace = true }
… +22 lines
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
[dev-dependencies]
tempfile = "3"
⚡ Running sed -n '1,200p' tui/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-tui"
version = { workspace = true }
… +74 lines
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
vt100 = "0.16.2"
⚡ Running sed -n '1,200p' chatgpt/Cargo.toml
⎿ [package]
edition = "2024"
name = "codex-chatgpt"
version = { workspace = true }
… +11 lines
serde_json = "1"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tempfile = "3"
thinking
Considering binary size impacts
I’m evaluating how binary size is influenced by various factors. There are many
binaries, such as codex and codex-exec, which include dependencies by default,
pulling in features like Tokio and hyper. For instance, reqwest includes
default features like TLS through rustls. If default features remain enabled,
it inflates the size with many additional components. I notice that for musl
targets, using vendored builds of OpenSSL could significantly increase binary
size. This is quite the intricate web of dependencies!
Analyzing musl and binary size
I’m considering the implications of building for musl with static linking.
In this case, OpenSSL might come into play through some crates, but reqwest
defaults to using rustls, although openssl-sys vendored could increase binary
size. It’s interesting to think about whether they’re targeting musl, especially
since it seems they might often be building for Mac and Linux with dynamic
linking instead.
I also realize there’s a difference between debug and release builds — release
should be smaller due to optimizations, but if they’re defaulting to debug
builds, that could lead to much larger binaries.
Assessing crate impacts on size
I need to be precise when analyzing binary sizes from various crates. Some
crates, like ratatui-image, include heavy features such as libjpeg decoders,
which can really bulk things up. Then there are the tree-sitter crates that
contain large static grammar tables, adding significant size. The starlark crate
and tracing-subscriber also contribute to size increases.
With static linking per binary being standard in Rust, it leads to multiple
copies of the same dependencies unless cdylibs are used. That makes the compile
times longer, but using LTO helps reduce bloat somewhat through deduplication.
Evaluating binary size factors
Having multiple binaries indeed increases the overall size significantly.
I'm considering options like upgrading to "opt-level = 'z'" or using
"strip=debuginfo" for reducing size. It's interesting that while the profile
only sets symbols for stripping, not debuginfo, this could inflate sizes if
retained in release builds.
The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps
debug information which may not align with the goal of minimizing size. I’d like
to verify with the Rust documentation, but my network access is restricted.
Explaining large binary sizes
I’m analyzing why binaries in this project are so large. The primary reasons
include monolithic static linking, where each binary includes the full
dependency graph without shared libraries. Heavy dependencies such as reqwest,
hyper, and TLS components also contribute significantly to size, along with
image decoders and other libraries.
Using MUSL targets means vendored OpenSSL can drastically increase the size due
to static linking. Furthermore, if the release profile keeps debug information
due to using "strip = symbols," that can lead to larger binaries as well. It’s a
mix of several factors, including panic strategies and platform-specific linking
behavior.
Identifying binary size contributors
I’m considering why binaries are still large even without static linking. The
tracing-subscriber crate potentially adds weight because it includes regex,
though it can sometimes use a lighter version. References from files like "core/
Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the
image crate.
To present this clearly, I’ll format the final answer with two main sections:
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question.
codex
Here’s what’s driving size in this workspace’s binaries.
Main Causes
- Static linking style: Each bin (codex, codex-tui, codex-exec,
codex-mcp-server, etc.) statically links its full dependency graph, so common
code isn’t shared at runtime across executables.
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, and a TLS
stack (rustls by default; OpenSSL on musl). In core, login, tui, and ollama you
enable reqwest with json/stream, which still pulls a large transitive set.
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
crossterm, and ratatui-image, which together contribute significant code size
(decoders, pixel ops, terminal backends).
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, signal)
across many crates inflate code size even if only subsets are used per bin.
- Panic + backtraces: Default panic = unwind and backtrace support keep
unwinding tables and symbols that add weight.
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-sys
with vendored, compiling OpenSSL into the binary—this adds multiple megabytes
per executable.
Build-Mode Notes
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
contributor; if you build in release with that setting, binaries can still be
large.
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
assertions—outputs are much larger than cargo build --release.
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level
= "z", panic abort, tighter tokio/reqwest features) and estimate impact per
binary.
File diff suppressed because one or more lines are too long
+38 -6
View File
@@ -75,7 +75,7 @@ impl TestScenario {
}
#[test]
fn hist_001_basic_insertion_no_wrap() {
fn basic_insertion_no_wrap() {
// Screen of 20x6; viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -97,7 +97,7 @@ fn hist_001_basic_insertion_no_wrap() {
}
#[test]
fn hist_002_long_token_wraps() {
fn long_token_wraps() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -130,7 +130,7 @@ fn hist_002_long_token_wraps() {
}
#[test]
fn hist_003_emoji_and_cjk() {
fn emoji_and_cjk() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -148,7 +148,7 @@ fn hist_003_emoji_and_cjk() {
}
#[test]
fn hist_004_mixed_ansi_spans() {
fn mixed_ansi_spans() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -162,7 +162,7 @@ fn hist_004_mixed_ansi_spans() {
}
#[test]
fn hist_006_cursor_restoration() {
fn cursor_restoration() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -182,7 +182,39 @@ fn hist_006_cursor_restoration() {
}
#[test]
fn hist_005_pre_scroll_region_down() {
fn word_wrap_no_mid_word_split() {
// Screen of 40x10; viewport is the last row
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
}
#[test]
fn em_dash_and_space_word_wrap() {
// Repro from report: ensure we break before "inside", not mid-word.
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
let buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("insi\nde"),
"word 'inside' should not be split across lines:\n{joined}"
);
}
#[test]
fn pre_scroll_region_down() {
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -0,0 +1,77 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::text::Line;
fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal<TestBackend> {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.unwrap_or_else(|e| panic!("failed to construct terminal: {e}"));
term.set_viewport_area(viewport);
term
}
#[test]
fn stream_commit_trickle_no_duplication() {
// Viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Step 1: commit first row
let mut out1 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out1,
vec![Line::from("one")],
);
// Step 2: later commit next row
let mut out2 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out2,
vec![Line::from("two")],
);
let combined = [out1, out2].concat();
let s = String::from_utf8_lossy(&combined);
assert_eq!(
s.matches("one").count(),
1,
"history line duplicated: {s:?}"
);
assert_eq!(
s.matches("two").count(),
1,
"history line duplicated: {s:?}"
);
assert!(
!s.contains("three"),
"live-only content leaked into history: {s:?}"
);
}
#[test]
fn live_ring_rows_not_inserted_into_history() {
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Commit two rows to history.
let mut buf = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut buf,
vec![Line::from("one"), Line::from("two")],
);
// The live ring might display tail+head rows like ["two", "three"],
// but only committed rows should be present in the history ANSI stream.
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("one"));
assert!(s.contains("two"));
assert!(
!s.contains("three"),
"uncommitted live-ring content should not be inserted into history: {s:?}"
);
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "codex-monorepo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-monorepo",
"devDependencies": {
"prettier": "^3.5.3"
},
"engines": {
"node": ">=22",
"pnpm": ">=9.0.0"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More