Compare commits

...

7 Commits

Author SHA1 Message Date
Ahmed Ibrahim
ab5a129a7d Revamp status UI with rate limit reset times 2025-09-24 13:26:45 -07:00
Jeremy Rose
7bff8df10e hide the status indicator when the answer stream starts (#4101)
This eliminates a "bounce" at the end of streaming where we hide the
status indicator at the end of the turn and the composer moves up two
lines.

Also, simplify streaming further by removing the HistorySink and
inverting control, and collapsing a few single-element structures.
2025-09-24 11:51:48 -07:00
pakrym-oai
addc946d13 Simplify tool implemetations (#4160)
Use Result<String, FunctionCallError> for all tool handling code and
rely on error propagation instead of creating failed items everywhere.
2025-09-24 17:27:35 +00:00
dependabot[bot]
bffdbec2c5 chore(deps): bump chrono from 0.4.41 to 0.4.42 in /codex-rs (#4028)
Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.41 to
0.4.42.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/chronotope/chrono/releases">chrono's
releases</a>.</em></p>
<blockquote>
<h2>0.4.42</h2>
<h2>What's Changed</h2>
<ul>
<li>Add fuzzer for DateTime::parse_from_str by <a
href="https://github.com/tyler92"><code>@​tyler92</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1700">chronotope/chrono#1700</a></li>
<li>Fix wrong amount of micro/milliseconds by <a
href="https://github.com/nmlt"><code>@​nmlt</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1703">chronotope/chrono#1703</a></li>
<li>Add warning about MappedLocalTime and wasm by <a
href="https://github.com/lutzky"><code>@​lutzky</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1702">chronotope/chrono#1702</a></li>
<li>Fix incorrect parsing of fixed-length second fractions by <a
href="https://github.com/chris-leach"><code>@​chris-leach</code></a> in
<a
href="https://redirect.github.com/chronotope/chrono/pull/1705">chronotope/chrono#1705</a></li>
<li>Fix cfgs for <code>wasm32-linux</code> support by <a
href="https://github.com/arjunr2"><code>@​arjunr2</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1707">chronotope/chrono#1707</a></li>
<li>Fix OpenHarmony's <code>tzdata</code> parsing by <a
href="https://github.com/ldm0"><code>@​ldm0</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1679">chronotope/chrono#1679</a></li>
<li>Convert NaiveDate to/from days since unix epoch by <a
href="https://github.com/findepi"><code>@​findepi</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1715">chronotope/chrono#1715</a></li>
<li>Add <code>?Sized</code> bound to related methods of
<code>DelayedFormat::write_to</code> by <a
href="https://github.com/Huliiiiii"><code>@​Huliiiiii</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1721">chronotope/chrono#1721</a></li>
<li>Add <code>from_timestamp_secs</code> method to <code>DateTime</code>
by <a href="https://github.com/jasonaowen"><code>@​jasonaowen</code></a>
in <a
href="https://redirect.github.com/chronotope/chrono/pull/1719">chronotope/chrono#1719</a></li>
<li>Migrate to core::error::Error by <a
href="https://github.com/benbrittain"><code>@​benbrittain</code></a> in
<a
href="https://redirect.github.com/chronotope/chrono/pull/1704">chronotope/chrono#1704</a></li>
<li>Upgrade to windows-bindgen 0.63 by <a
href="https://github.com/djc"><code>@​djc</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1730">chronotope/chrono#1730</a></li>
<li>strftime: simplify error handling by <a
href="https://github.com/djc"><code>@​djc</code></a> in <a
href="https://redirect.github.com/chronotope/chrono/pull/1731">chronotope/chrono#1731</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="f3fd15f976"><code>f3fd15f</code></a>
Bump version to 0.4.42</li>
<li><a
href="5cf5603500"><code>5cf5603</code></a>
strftime: add regression test case</li>
<li><a
href="a6231701ee"><code>a623170</code></a>
strftime: simplify error handling</li>
<li><a
href="36fbfb1221"><code>36fbfb1</code></a>
strftime: move specifier handling out of match to reduce rightward
drift</li>
<li><a
href="7f413c363b"><code>7f413c3</code></a>
strftime: yield None early</li>
<li><a
href="9d5dfe1640"><code>9d5dfe1</code></a>
strftime: outline constants</li>
<li><a
href="e5f6be7db4"><code>e5f6be7</code></a>
strftime: move error() method below caller</li>
<li><a
href="d516c2764d"><code>d516c27</code></a>
strftime: merge impl blocks</li>
<li><a
href="0ee2172fb9"><code>0ee2172</code></a>
strftime: re-order items to keep impls together</li>
<li><a
href="757a8b0226"><code>757a8b0</code></a>
Upgrade to windows-bindgen 0.63</li>
<li>Additional commits viewable in <a
href="https://github.com/chronotope/chrono/compare/v0.4.41...v0.4.42">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=chrono&package-manager=cargo&previous-version=0.4.41&new-version=0.4.42)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:53:26 +00:00
dependabot[bot]
353a5c2046 chore(deps): bump unicode-width from 0.1.14 to 0.2.1 in /codex-rs (#2156)
Bumps [unicode-width](https://github.com/unicode-rs/unicode-width) from
0.1.14 to 0.2.1.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0085e91db7"><code>0085e91</code></a>
Publish 0.2.1</li>
<li><a
href="6db0c14cbd"><code>6db0c14</code></a>
Remove <code>compiler-builtins</code> from <code>rustc-dep-of-std</code>
dependencies (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/77">#77</a>)</li>
<li><a
href="0bccd3f1b5"><code>0bccd3f</code></a>
update copyright year (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/76">#76</a>)</li>
<li><a
href="7a7fcdc813"><code>7a7fcdc</code></a>
Support Unicode 16 (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/74">#74</a>)</li>
<li><a
href="82d7136b49"><code>82d7136</code></a>
Advertise and enforce MSRV (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/73">#73</a>)</li>
<li><a
href="e77b2929bc"><code>e77b292</code></a>
Make characters with <code>Line_Break=Ambiguous</code> ambiguous (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/61">#61</a>)</li>
<li><a
href="5a7fced663"><code>5a7fced</code></a>
Update version number in Readme (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/70">#70</a>)</li>
<li><a
href="79eab0d9fc"><code>79eab0d</code></a>
Publish 0.2.0 with newlines treated as width 1 (<a
href="https://redirect.github.com/unicode-rs/unicode-width/issues/68">#68</a>)</li>
<li>See full diff in <a
href="https://github.com/unicode-rs/unicode-width/compare/v0.1.14...v0.2.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=unicode-width&package-manager=cargo&previous-version=0.1.14&new-version=0.2.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:33:46 +00:00
Tien Nguyen
00c7f7a16c chore: remove once_cell dependency from multiple crates (#4154)
This commit removes the `once_cell` dependency from `Cargo.toml` files
in the `codex-rs` and `apply-patch` directories, replacing its usage
with `std::sync::LazyLock` and `std::sync::OnceLock` where applicable.
This change simplifies the dependency tree and utilizes standard library
features for lazy initialization.

# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.
2025-09-24 09:15:57 -07:00
Michael Bolin
82e65975b2 fix: add tolerance for ambiguous behavior in gh run list (#4162)
I am not sure what is going on, as
https://github.com/openai/codex/pull/3660 introduced this new logic and
I swear that CI was green before I merged that PR, but I am seeing
failures in this CI job this morning. This feels like a
non-backwards-compatible change in `gh`, but that feels unlikely...

Nevertheless, this is what I currently see on my laptop:

```
$ gh --version
gh version 2.76.2 (2025-07-30)
https://github.com/cli/cli/releases/tag/v2.76.2
$ gh run list --workflow .github/workflows/rust-release.yml --branch rust-v0.40.0 --json workflowName,url,headSha --jq 'first(.[])'
{
  "headSha": "5268705a69713752adcbd8416ef9e84a683f7aa3",
  "url": "https://github.com/openai/codex/actions/runs/17952349351",
  "workflowName": ".github/workflows/rust-release.yml"
}
```

Looking at sample output from an old GitHub issue
(https://github.com/cli/cli/issues/6678), it appears that, at least at
one point in time, the `workflowName` was _not_ the path to the
workflow.
2025-09-24 09:15:03 -07:00
35 changed files with 1280 additions and 1225 deletions

View File

@@ -27,26 +27,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# build_npm_package.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@v2
# Run all tasks using workspace filters
- name: Stage npm package
- name: Ensure staging a release works.
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
CODEX_VERSION=0.40.0
PACK_OUTPUT="${RUNNER_TEMP}/codex-npm.tgz"
python3 ./codex-cli/scripts/build_npm_package.py \
--release-version "$CODEX_VERSION" \
--pack-output "$PACK_OUTPUT"
echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV"
- name: Upload staged npm package artifact
uses: actions/upload-artifact@v4
with:
name: codex-npm-staging
path: ${{ env.PACK_OUTPUT }}
run: ./codex-cli/scripts/stage_release.sh
- name: Ensure root README.md contains only ASCII and certain Unicode code points
run: ./scripts/asciicheck.py README.md

View File

@@ -57,7 +57,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
- uses: dtolnay/rust-toolchain@1.89
with:
components: rustfmt
- name: cargo fmt
@@ -75,7 +75,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
- uses: dtolnay/rust-toolchain@1.89
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: cargo-shear
@@ -143,7 +143,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
- uses: dtolnay/rust-toolchain@1.89
with:
targets: ${{ matrix.target }}
components: clippy

View File

@@ -77,7 +77,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
- uses: dtolnay/rust-toolchain@1.89
with:
targets: ${{ matrix.target }}
@@ -173,8 +173,6 @@ jobs:
outputs:
version: ${{ steps.release_name.outputs.name }}
tag: ${{ github.ref_name }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
steps:
- name: Checkout repository
@@ -195,37 +193,21 @@ jobs:
version="${GITHUB_REF_NAME#rust-v}"
echo "name=${version}" >> $GITHUB_OUTPUT
- name: Determine npm publish settings
id: npm_publish_settings
env:
VERSION: ${{ steps.release_name.outputs.name }}
run: |
set -euo pipefail
version="${VERSION}"
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=alpha" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
fi
# build_npm_package.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@v2
- name: Stage npm package
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TMP_DIR="${RUNNER_TEMP}/npm-stage"
./codex-cli/scripts/build_npm_package.py \
python3 codex-cli/scripts/stage_rust_release.py \
--release-version "${{ steps.release_name.outputs.name }}" \
--staging-dir "${TMP_DIR}" \
--pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
--tmp "${TMP_DIR}"
mkdir -p dist/npm
# Produce an npm-ready tarball using `npm pack` and store it in dist/npm.
# We then rename it to a stable name used by our publishing script.
(cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \
"${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -248,8 +230,8 @@ jobs:
# July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/
# npm docs: https://docs.npmjs.com/trusted-publishers
publish-npm:
# Publish to npm for stable releases and alpha pre-releases with numeric suffixes.
if: ${{ needs.release.outputs.should_publish_npm == 'true' }}
# Skip this step for pre-releases (alpha/beta).
if: ${{ !contains(needs.release.outputs.version, '-') }}
name: publish-npm
needs: release
runs-on: ubuntu-latest
@@ -284,17 +266,7 @@ jobs:
# No NODE_AUTH_TOKEN needed because we use OIDC.
- name: Publish to npm
env:
VERSION: ${{ needs.release.outputs.version }}
NPM_TAG: ${{ needs.release.outputs.npm_tag }}
run: |
set -euo pipefail
tag_args=()
if [[ -n "${NPM_TAG}" ]]; then
tag_args+=(--tag "${NPM_TAG}")
fi
npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}"
run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ needs.release.outputs.version }}.tgz"
update-branch:
name: Update latest-alpha-cli branch

View File

@@ -1 +1,7 @@
/vendor/
# Added by ./scripts/install_native_deps.sh
/bin/codex-aarch64-apple-darwin
/bin/codex-aarch64-unknown-linux-musl
/bin/codex-linux-sandbox-arm64
/bin/codex-linux-sandbox-x64
/bin/codex-x86_64-apple-darwin
/bin/codex-x86_64-unknown-linux-musl

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
// Unified entry point for the Codex CLI.
import { existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
@@ -41,10 +40,10 @@ switch (platform) {
case "win32":
switch (arch) {
case "x64":
targetTriple = "x86_64-pc-windows-msvc";
targetTriple = "x86_64-pc-windows-msvc.exe";
break;
case "arm64":
targetTriple = "aarch64-pc-windows-msvc";
targetTriple = "aarch64-pc-windows-msvc.exe";
break;
default:
break;
@@ -58,10 +57,7 @@ if (!targetTriple) {
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}
const vendorRoot = path.join(__dirname, "..", "vendor");
const archRoot = path.join(vendorRoot, targetTriple);
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
// Use an asynchronous spawn instead of spawnSync so that Node is able to
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
@@ -70,6 +66,23 @@ const binaryPath = path.join(archRoot, "codex", codexBinaryName);
// 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 || "";
@@ -81,9 +94,9 @@ function getUpdatedPath(newDirs) {
}
const additionalDirs = [];
const pathDir = path.join(archRoot, "path");
if (existsSync(pathDir)) {
additionalDirs.push(pathDir);
const rgDir = await resolveRgDir();
if (rgDir) {
additionalDirs.push(rgDir);
}
const updatedPath = getUpdatedPath(additionalDirs);

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env dotslash
{
"name": "rg",
"platforms": {
"macos-aarch64": {
"size": 1787248,
"hash": "blake3",
"digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a",
"format": "tar.gz",
"path": "ripgrep-14.1.1-aarch64-apple-darwin/rg",
"providers": [
{
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
}
]
},
"linux-aarch64": {
"size": 2047405,
"hash": "blake3",
"digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515",
"format": "tar.gz",
"path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg",
"providers": [
{
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz"
}
]
},
"macos-x86_64": {
"size": 2082672,
"hash": "blake3",
"digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e",
"format": "tar.gz",
"path": "ripgrep-14.1.1-x86_64-apple-darwin/rg",
"providers": [
{
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"
}
]
},
"linux-x86_64": {
"size": 2566310,
"hash": "blake3",
"digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76",
"format": "tar.gz",
"path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg",
"providers": [
{
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz"
}
]
},
"windows-x86_64": {
"size": 2058893,
"hash": "blake3",
"digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd",
"format": "zip",
"path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe",
"providers": [
{
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip"
}
]
},
"windows-aarch64": {
"size": 1667740,
"hash": "blake3",
"digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa",
"format": "zip",
"path": "rg.exe",
"providers": [
{
"url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip"
}
]
}
}
}

View File

@@ -2,17 +2,118 @@
"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"
}
}
}
}

View File

@@ -11,11 +11,17 @@
},
"files": [
"bin",
"vendor"
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",
"directory": "codex-cli"
},
"dependencies": {
"@vscode/ripgrep": "^1.15.14"
},
"devDependencies": {
"prettier": "^3.3.3"
}
}

View File

@@ -5,7 +5,5 @@ Run the following:
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
```bash
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
./codex-cli/scripts/stage_rust_release.py --release-version 0.6.0
```
Note this will create `./codex-cli/vendor/` as a side-effect.

View File

@@ -1,294 +0,0 @@
#!/usr/bin/env python3
"""Stage and optionally package the @openai/codex npm module."""
import argparse
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
CODEX_CLI_ROOT = SCRIPT_DIR.parent
REPO_ROOT = CODEX_CLI_ROOT.parent
GITHUB_REPO = "openai/codex"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
parser.add_argument(
"--version",
help="Version number to write to package.json inside the staged package.",
)
parser.add_argument(
"--release-version",
help=(
"Version to stage for npm release. When provided, the script also resolves the "
"matching rust-release workflow unless --workflow-url is supplied."
),
)
parser.add_argument(
"--workflow-url",
help="Optional GitHub Actions workflow run URL used to download native binaries.",
)
parser.add_argument(
"--staging-dir",
type=Path,
help=(
"Directory to stage the package contents. Defaults to a new temporary directory "
"if omitted. The directory must be empty when provided."
),
)
parser.add_argument(
"--tmp",
dest="staging_dir",
type=Path,
help=argparse.SUPPRESS,
)
parser.add_argument(
"--pack-output",
type=Path,
help="Path where the generated npm tarball should be written.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
version = args.version
release_version = args.release_version
if release_version:
if version and version != release_version:
raise RuntimeError("--version and --release-version must match when both are provided.")
version = release_version
if not version:
raise RuntimeError("Must specify --version or --release-version.")
staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
try:
stage_sources(staging_dir, version)
workflow_url = args.workflow_url
resolved_head_sha: str | None = None
if not workflow_url:
if release_version:
workflow = resolve_release_workflow(version)
workflow_url = workflow["url"]
resolved_head_sha = workflow.get("headSha")
else:
workflow_url = resolve_latest_alpha_workflow_url()
elif release_version:
try:
workflow = resolve_release_workflow(version)
resolved_head_sha = workflow.get("headSha")
except Exception:
resolved_head_sha = None
if release_version and resolved_head_sha:
print(f"should `git checkout {resolved_head_sha}`")
if not workflow_url:
raise RuntimeError("Unable to determine workflow URL for native binaries.")
install_native_binaries(staging_dir, workflow_url)
if release_version:
staging_dir_str = str(staging_dir)
print(
f"Staged version {version} for release in {staging_dir_str}\n\n"
"Verify the CLI:\n"
f" node {staging_dir_str}/bin/codex.js --version\n"
f" node {staging_dir_str}/bin/codex.js --help\n\n"
)
else:
print(f"Staged package in {staging_dir}")
if args.pack_output is not None:
output_path = run_npm_pack(staging_dir, args.pack_output)
print(f"npm pack output written to {output_path}")
finally:
if created_temp:
# Preserve the staging directory for further inspection.
pass
return 0
def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]:
if staging_dir is not None:
staging_dir = staging_dir.resolve()
staging_dir.mkdir(parents=True, exist_ok=True)
if any(staging_dir.iterdir()):
raise RuntimeError(f"Staging directory {staging_dir} is not empty.")
return staging_dir, False
temp_dir = Path(tempfile.mkdtemp(prefix="codex-npm-stage-"))
return temp_dir, True
def stage_sources(staging_dir: Path, version: str) -> None:
bin_dir = staging_dir / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
if rg_manifest.exists():
shutil.copy2(rg_manifest, bin_dir / "rg")
readme_src = REPO_ROOT / "README.md"
if readme_src.exists():
shutil.copy2(readme_src, staging_dir / "README.md")
with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh:
package_json = json.load(fh)
package_json["version"] = version
with open(staging_dir / "package.json", "w", encoding="utf-8") as out:
json.dump(package_json, out, indent=2)
out.write("\n")
def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
cmd = ["./scripts/install_native_deps.py"]
if workflow_url:
cmd.extend(["--workflow-url", workflow_url])
cmd.append(str(staging_dir))
subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT)
def resolve_latest_alpha_workflow_url() -> str:
version = determine_latest_alpha_version()
workflow_url = fetch_workflow_url_for_version(version)
if not workflow_url:
raise RuntimeError(f"Unable to locate workflow for version {version}.")
return workflow_url
def determine_latest_alpha_version() -> str:
releases = list_releases()
best_key: tuple[int, int, int, int] | None = None
best_version: str | None = None
pattern = re.compile(r"^rust-v(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$")
for release in releases:
tag = release.get("tag_name", "")
match = pattern.match(tag)
if not match:
continue
key = tuple(int(match.group(i)) for i in range(1, 5))
if best_key is None or key > best_key:
best_key = key
best_version = (
f"{match.group(1)}.{match.group(2)}.{match.group(3)}-alpha.{match.group(4)}"
)
if best_version is None:
raise RuntimeError("No alpha releases found when resolving workflow URL.")
return best_version
def list_releases() -> list[dict]:
stdout = subprocess.check_output(
["gh", "api", f"/repos/{GITHUB_REPO}/releases?per_page=100"],
text=True,
)
try:
releases = json.loads(stdout or "[]")
except json.JSONDecodeError as exc:
raise RuntimeError("Unable to parse releases JSON.") from exc
if not isinstance(releases, list):
raise RuntimeError("Unexpected response when listing releases.")
return releases
def fetch_workflow_url_for_version(version: str) -> str | None:
ref = f"rust-v{version}"
stdout = subprocess.check_output(
[
"gh",
"run",
"list",
"--branch",
ref,
"--limit",
"20",
"--json",
"workflowName,url",
],
text=True,
)
try:
runs = json.loads(stdout or "[]")
except json.JSONDecodeError as exc:
raise RuntimeError("Unable to parse workflow run listing.") from exc
for run in runs:
if run.get("workflowName") == "rust-release":
url = run.get("url")
if url:
return url
return None
def resolve_release_workflow(version: str) -> dict:
stdout = subprocess.check_output(
[
"gh",
"run",
"list",
"--branch",
f"rust-v{version}",
"--json",
"workflowName,url,headSha",
"--jq",
'first(.[] | select(.workflowName == "rust-release"))',
],
text=True,
)
workflow = json.loads(stdout)
if not workflow:
raise RuntimeError(f"Unable to find rust-release workflow for version {version}.")
return workflow
def run_npm_pack(staging_dir: Path, output_path: Path) -> Path:
output_path = output_path.resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str:
pack_dir = Path(pack_dir_str)
stdout = subprocess.check_output(
["npm", "pack", "--json", "--pack-destination", str(pack_dir)],
cwd=staging_dir,
text=True,
)
try:
pack_output = json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError("Failed to parse npm pack output.") from exc
if not pack_output:
raise RuntimeError("npm pack did not produce an output tarball.")
tarball_name = pack_output[0].get("filename") or pack_output[0].get("name")
if not tarball_name:
raise RuntimeError("Unable to determine npm pack output filename.")
tarball_path = pack_dir / tarball_name
if not tarball_path.exists():
raise RuntimeError(f"Expected npm pack output not found: {tarball_path}")
shutil.move(str(tarball_path), output_path)
return output_path
if __name__ == "__main__":
import sys
sys.exit(main())

View File

@@ -1,318 +0,0 @@
#!/usr/bin/env python3
"""Install Codex native binaries (Rust CLI plus ripgrep helpers)."""
import argparse
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Iterable, Sequence
from urllib.parse import urlparse
from urllib.request import urlopen
SCRIPT_DIR = Path(__file__).resolve().parent
CODEX_CLI_ROOT = SCRIPT_DIR.parent
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
VENDOR_DIR_NAME = "vendor"
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
CODEX_TARGETS = (
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
)
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
("x86_64-unknown-linux-musl", "linux-x86_64"),
("aarch64-unknown-linux-musl", "linux-aarch64"),
("x86_64-apple-darwin", "macos-x86_64"),
("aarch64-apple-darwin", "macos-aarch64"),
("x86_64-pc-windows-msvc", "windows-x86_64"),
("aarch64-pc-windows-msvc", "windows-aarch64"),
]
RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS}
DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Install native Codex binaries.")
parser.add_argument(
"--workflow-url",
help=(
"GitHub Actions workflow URL that produced the artifacts. Defaults to a "
"known good run when omitted."
),
)
parser.add_argument(
"root",
nargs="?",
type=Path,
help=(
"Directory containing package.json for the staged package. If omitted, the "
"repository checkout is used."
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve()
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
if not workflow_url:
workflow_url = DEFAULT_WORKFLOW_URL
workflow_id = workflow_url.rstrip("/").split("/")[-1]
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
artifacts_dir = Path(artifacts_dir_str)
_download_artifacts(workflow_id, artifacts_dir)
install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS)
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
print(f"Installed native dependencies into {vendor_dir}")
return 0
def fetch_rg(
vendor_dir: Path,
targets: Sequence[str] | None = None,
*,
manifest_path: Path,
) -> list[Path]:
"""Download ripgrep binaries described by the DotSlash manifest."""
if targets is None:
targets = DEFAULT_RG_TARGETS
if not manifest_path.exists():
raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}")
manifest = _load_manifest(manifest_path)
platforms = manifest.get("platforms", {})
vendor_dir.mkdir(parents=True, exist_ok=True)
targets = list(targets)
if not targets:
return []
task_configs: list[tuple[str, str, dict]] = []
for target in targets:
platform_key = RG_TARGET_TO_PLATFORM.get(target)
if platform_key is None:
raise ValueError(f"Unsupported ripgrep target '{target}'.")
platform_info = platforms.get(platform_key)
if platform_info is None:
raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.")
task_configs.append((target, platform_key, platform_info))
results: dict[str, Path] = {}
max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(
_fetch_single_rg,
vendor_dir,
target,
platform_key,
platform_info,
manifest_path,
): target
for target, platform_key, platform_info in task_configs
}
for future in as_completed(future_map):
target = future_map[future]
results[target] = future.result()
return [results[target] for target in targets]
def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
cmd = [
"gh",
"run",
"download",
"--dir",
str(dest_dir),
"--repo",
"openai/codex",
workflow_id,
]
subprocess.check_call(cmd)
def install_codex_binaries(
artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str]
) -> list[Path]:
targets = list(targets)
if not targets:
return []
results: dict[str, Path] = {}
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target
for target in targets
}
for future in as_completed(future_map):
target = future_map[future]
results[target] = future.result()
return [results[target] for target in targets]
def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path:
artifact_subdir = artifacts_dir / target
archive_name = _archive_name_for_target(target)
archive_path = artifact_subdir / archive_name
if not archive_path.exists():
raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
dest_dir = vendor_dir / target / "codex"
dest_dir.mkdir(parents=True, exist_ok=True)
binary_name = "codex.exe" if "windows" in target else "codex"
dest = dest_dir / binary_name
dest.unlink(missing_ok=True)
extract_archive(archive_path, "zst", None, dest)
if "windows" not in target:
dest.chmod(0o755)
return dest
def _archive_name_for_target(target: str) -> str:
if "windows" in target:
return f"codex-{target}.exe.zst"
return f"codex-{target}.zst"
def _fetch_single_rg(
vendor_dir: Path,
target: str,
platform_key: str,
platform_info: dict,
manifest_path: Path,
) -> Path:
providers = platform_info.get("providers", [])
if not providers:
raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.")
url = providers[0]["url"]
archive_format = platform_info.get("format", "zst")
archive_member = platform_info.get("path")
dest_dir = vendor_dir / target / "path"
dest_dir.mkdir(parents=True, exist_ok=True)
is_windows = platform_key.startswith("win")
binary_name = "rg.exe" if is_windows else "rg"
dest = dest_dir / binary_name
with tempfile.TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
archive_filename = os.path.basename(urlparse(url).path)
download_path = tmp_dir / archive_filename
_download_file(url, download_path)
dest.unlink(missing_ok=True)
extract_archive(download_path, archive_format, archive_member, dest)
if not is_windows:
dest.chmod(0o755)
return dest
def _download_file(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
with urlopen(url) as response, open(dest, "wb") as out:
shutil.copyfileobj(response, out)
def extract_archive(
archive_path: Path,
archive_format: str,
archive_member: str | None,
dest: Path,
) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
if archive_format == "zst":
output_path = archive_path.parent / dest.name
subprocess.check_call(
["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]
)
shutil.move(str(output_path), dest)
return
if archive_format == "tar.gz":
if not archive_member:
raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.")
with tarfile.open(archive_path, "r:gz") as tar:
try:
member = tar.getmember(archive_member)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
tar.extract(member, path=archive_path.parent, filter="data")
extracted = archive_path.parent / archive_member
shutil.move(str(extracted), dest)
return
if archive_format == "zip":
if not archive_member:
raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")
with zipfile.ZipFile(archive_path) as archive:
try:
with archive.open(archive_member) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
return
raise RuntimeError(f"Unsupported archive format '{archive_format}'.")
def _load_manifest(manifest_path: Path) -> dict:
cmd = ["dotslash", "--", "parse", str(manifest_path)]
stdout = subprocess.check_output(cmd, text=True)
try:
manifest = json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc
if not isinstance(manifest, dict):
raise RuntimeError(
f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}"
)
return manifest
if __name__ == "__main__":
import sys
sys.exit(main())

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Install native runtime dependencies for codex-cli.
#
# Usage
# install_native_deps.sh [--workflow-url URL] [CODEX_CLI_ROOT]
#
# The optional RELEASE_ROOT is the path that contains package.json. Omitting
# it installs the binaries into the repository's own bin/ folder to support
# local development.
set -euo pipefail
# ------------------
# Parse arguments
# ------------------
CODEX_CLI_ROOT=""
# Until we start publishing stable GitHub releases, we have to grab the binaries
# from the GitHub Action that created them. Update the URL below to point to the
# appropriate workflow run:
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/17417194663" # rust-v0.28.0
while [[ $# -gt 0 ]]; do
case "$1" in
--workflow-url)
shift || { echo "--workflow-url requires an argument"; exit 1; }
if [ -n "$1" ]; then
WORKFLOW_URL="$1"
fi
;;
*)
if [[ -z "$CODEX_CLI_ROOT" ]]; then
CODEX_CLI_ROOT="$1"
else
echo "Unexpected argument: $1" >&2
exit 1
fi
;;
esac
shift
done
# ----------------------------------------------------------------------------
# Determine where the binaries should be installed.
# ----------------------------------------------------------------------------
if [ -n "$CODEX_CLI_ROOT" ]; then
# The caller supplied a release root directory.
BIN_DIR="$CODEX_CLI_ROOT/bin"
else
# No argument; fall back to the repos own bin directory.
# Resolve the path of this script, then walk up to the repo root.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BIN_DIR="$CODEX_CLI_ROOT/bin"
fi
# Make sure the destination directory exists.
mkdir -p "$BIN_DIR"
# ----------------------------------------------------------------------------
# Download and decompress the artifacts from the GitHub Actions workflow.
# ----------------------------------------------------------------------------
WORKFLOW_ID="${WORKFLOW_URL##*/}"
ARTIFACTS_DIR="$(mktemp -d)"
trap 'rm -rf "$ARTIFACTS_DIR"' EXIT
# NB: The GitHub CLI `gh` must be installed and authenticated.
gh run download --dir "$ARTIFACTS_DIR" --repo openai/codex "$WORKFLOW_ID"
# x64 Linux
zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-x86_64-unknown-linux-musl.zst" \
-o "$BIN_DIR/codex-x86_64-unknown-linux-musl"
# ARM64 Linux
zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-musl/codex-aarch64-unknown-linux-musl.zst" \
-o "$BIN_DIR/codex-aarch64-unknown-linux-musl"
# x64 macOS
zstd -d "$ARTIFACTS_DIR/x86_64-apple-darwin/codex-x86_64-apple-darwin.zst" \
-o "$BIN_DIR/codex-x86_64-apple-darwin"
# ARM64 macOS
zstd -d "$ARTIFACTS_DIR/aarch64-apple-darwin/codex-aarch64-apple-darwin.zst" \
-o "$BIN_DIR/codex-aarch64-apple-darwin"
# x64 Windows
zstd -d "$ARTIFACTS_DIR/x86_64-pc-windows-msvc/codex-x86_64-pc-windows-msvc.exe.zst" \
-o "$BIN_DIR/codex-x86_64-pc-windows-msvc.exe"
# ARM64 Windows
zstd -d "$ARTIFACTS_DIR/aarch64-pc-windows-msvc/codex-aarch64-pc-windows-msvc.exe.zst" \
-o "$BIN_DIR/codex-aarch64-pc-windows-msvc.exe"
echo "Installed native dependencies into $BIN_DIR"

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# stage_release.sh
# -----------------------------------------------------------------------------
# Stages an npm release for @openai/codex.
#
# Usage:
#
# --tmp <dir> : Use <dir> instead of a freshly created temp directory.
# -h|--help : Print usage.
#
# -----------------------------------------------------------------------------
set -euo pipefail
# Helper - usage / flag parsing
usage() {
cat <<EOF
Usage: $(basename "$0") [--tmp DIR] [--version VERSION]
Options
--tmp DIR Use DIR to stage the release (defaults to a fresh mktemp dir)
--version Specify the version to release (defaults to a timestamp-based version)
-h, --help Show this help
Legacy positional argument: the first non-flag argument is still interpreted
as the temporary directory (for backwards compatibility) but is deprecated.
EOF
exit "${1:-0}"
}
TMPDIR=""
# Default to a timestamp-based version (keep same scheme as before)
VERSION="$(printf '0.1.%d' "$(date +%y%m%d%H%M)")"
WORKFLOW_URL=""
# Manual flag parser - Bash getopts does not handle GNU long options well.
while [[ $# -gt 0 ]]; do
case "$1" in
--tmp)
shift || { echo "--tmp requires an argument"; usage 1; }
TMPDIR="$1"
;;
--tmp=*)
TMPDIR="${1#*=}"
;;
--version)
shift || { echo "--version requires an argument"; usage 1; }
VERSION="$1"
;;
--workflow-url)
shift || { echo "--workflow-url requires an argument"; exit 1; }
WORKFLOW_URL="$1"
;;
-h|--help)
usage 0
;;
--*)
echo "Unknown option: $1" >&2
usage 1
;;
*)
echo "Unexpected extra argument: $1" >&2
usage 1
;;
esac
shift
done
# Fallback when the caller did not specify a directory.
# If no directory was specified create a fresh temporary one.
if [[ -z "$TMPDIR" ]]; then
TMPDIR="$(mktemp -d)"
fi
# Ensure the directory exists, then resolve to an absolute path.
mkdir -p "$TMPDIR"
TMPDIR="$(cd "$TMPDIR" && pwd)"
# Main build logic
echo "Staging release in $TMPDIR"
# The script lives in codex-cli/scripts/ - change into codex-cli root so that
# relative paths keep working.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
pushd "$CODEX_CLI_ROOT" >/dev/null
# 1. Build the JS artifacts ---------------------------------------------------
# Paths inside the staged package
mkdir -p "$TMPDIR/bin"
cp -r bin/codex.js "$TMPDIR/bin/codex.js"
cp ../README.md "$TMPDIR" || true # README is one level up - ignore if missing
# Modify package.json - bump version and optionally add the native directory to
# the files array so that the binaries are published to npm.
jq --arg version "$VERSION" \
'.version = $version' \
package.json > "$TMPDIR/package.json"
# 2. Native runtime deps (sandbox plus optional Rust binaries)
./scripts/install_native_deps.sh --workflow-url "$WORKFLOW_URL" "$TMPDIR"
popd >/dev/null
echo "Staged version $VERSION for release in $TMPDIR"
echo "Verify the CLI:"
echo " node ${TMPDIR}/bin/codex.js --version"
echo " node ${TMPDIR}/bin/codex.js --help"
# Print final hint for convenience
echo "Next: cd \"$TMPDIR\" && npm publish"

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import json
import subprocess
import sys
import argparse
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(
description="""Stage a release for the npm module.
Run this after the GitHub Release has been created and use
`--release-version` to specify the version to release.
Optionally pass `--tmp` to control the temporary staging directory that will be
forwarded to stage_release.sh.
"""
)
parser.add_argument(
"--release-version", required=True, help="Version to release, e.g., 0.3.0"
)
parser.add_argument(
"--tmp",
help="Optional path to stage the npm package; forwarded to stage_release.sh",
)
args = parser.parse_args()
version = args.release_version
gh_run = subprocess.run(
[
"gh",
"run",
"list",
"--branch",
f"rust-v{version}",
"--json",
"workflowName,url,headSha",
"--jq",
'first(.[] | select(.workflowName == "rust-release"))',
],
stdout=subprocess.PIPE,
check=True,
)
gh_run.check_returncode()
workflow = json.loads(gh_run.stdout)
sha = workflow["headSha"]
print(f"should `git checkout {sha}`")
current_dir = Path(__file__).parent.resolve()
cmd = [
str(current_dir / "stage_release.sh"),
"--version",
version,
"--workflow-url",
workflow["url"],
]
if args.tmp:
cmd.extend(["--tmp", args.tmp])
stage_release = subprocess.run(cmd)
stage_release.check_returncode()
return 0
if __name__ == "__main__":
sys.exit(main())

53
codex-rs/Cargo.lock generated
View File

@@ -664,10 +664,13 @@ dependencies = [
name = "codex-common"
version = "0.0.0"
dependencies = [
"async-trait",
"clap",
"codex-core",
"codex-protocol",
"serde",
"thiserror 2.0.16",
"tokio",
"toml",
]
@@ -983,16 +986,6 @@ dependencies = [
"vt100",
]
[[package]]
name = "codex-utils-readiness"
version = "0.0.0"
dependencies = [
"async-trait",
"thiserror 2.0.16",
"time",
"tokio",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -1288,12 +1281,12 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.4"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde_core",
"serde",
]
[[package]]
@@ -2693,9 +2686,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "logos"
@@ -3936,9 +3929,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.226"
version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b"
dependencies = [
"serde_core",
"serde_derive",
@@ -3946,18 +3939,18 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.226"
version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.226"
version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0"
dependencies = [
"proc-macro2",
"quote",
@@ -4460,15 +4453,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4592,9 +4585,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
@@ -4609,15 +4602,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.24"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",

View File

@@ -19,7 +19,6 @@ members = [
"protocol",
"protocol-ts",
"tui",
"utils/readiness",
]
resolver = "2"
@@ -50,7 +49,6 @@ codex-ollama = { path = "ollama" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-tui = { path = "tui" }
codex-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
@@ -123,7 +121,7 @@ strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
sys-locale = "0.3.2"
tempfile = "3.23.0"
tempfile = "3.13.0"
textwrap = "0.16.2"
thiserror = "2.0.16"
time = "0.3"
@@ -193,7 +191,7 @@ unwrap_used = "deny"
# cargo-shear cannot see the platform-specific openssl-sys usage, so we
# silence the false positive here instead of deleting a real dependency.
[workspace.metadata.cargo-shear]
ignored = ["openssl-sys", "codex-utils-readiness"]
ignored = ["openssl-sys"]
[profile.release]
lto = "fat"

View File

@@ -7,11 +7,14 @@ version = { workspace = true }
workspace = true
[dependencies]
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }
thiserror = { workspace = true }
tokio = { workspace = true }
[features]
# Separate feature so that `clap` is not a mandatory dependency.

View File

@@ -34,3 +34,5 @@ pub mod model_presets;
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
pub mod approval_presets;
// Readiness flag with token-based authorization and async waiting (Tokio).
pub mod readiness;

View File

@@ -415,6 +415,9 @@ struct SseEvent {
delta: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResponseCreated {}
#[derive(Debug, Deserialize)]
struct ResponseCompleted {
id: String,

View File

@@ -55,7 +55,7 @@ pub(super) async fn run_inline_auto_compact_task(
let input = vec![InputItem::Text {
text: SUMMARIZATION_PROMPT.to_string(),
}];
run_compact_task_inner(sess, turn_context, sub_id, input, false).await;
run_compact_task_inner(sess, turn_context, sub_id, input, None, false).await;
}
pub(super) async fn run_compact_task(
@@ -71,7 +71,15 @@ pub(super) async fn run_compact_task(
}),
};
sess.send_event(start_event).await;
run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input, true).await;
run_compact_task_inner(
sess.clone(),
turn_context,
sub_id.clone(),
input,
None,
true,
)
.await;
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent {
@@ -86,6 +94,7 @@ async fn run_compact_task_inner(
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
instructions_override: Option<String>,
remove_task_on_completion: bool,
) {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
@@ -95,7 +104,9 @@ async fn run_compact_task_inner(
let prompt = Prompt {
input: turn_input,
..Default::default()
tools: Vec::new(),
base_instructions_override: instructions_override,
output_schema: None,
};
let max_retries = turn_context.client.get_provider().stream_max_retries();

View File

@@ -7,14 +7,13 @@ use crate::model_family::ModelFamily;
/// Though this would help present more accurate pricing information in the UI.
#[derive(Debug)]
pub(crate) struct ModelInfo {
/// Size of the context window in tokens. This is the maximum size of the input context.
/// Size of the context window in tokens.
pub(crate) context_window: u64,
/// Maximum number of output tokens that can be generated for the model.
pub(crate) max_output_tokens: u64,
/// Token threshold where we should automatically compact conversation history. This considers
/// input tokens + output tokens of this turn.
/// Token threshold where we should automatically compact conversation history.
pub(crate) auto_compact_token_limit: Option<i64>,
}
@@ -65,7 +64,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
_ if slug.starts_with("gpt-5-codex") => Some(ModelInfo {
context_window: 272_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(350_000),
auto_compact_token_limit: Some(220_000),
}),
_ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)),

View File

@@ -7,6 +7,8 @@ use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::ConversationId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::FormatItem;
@@ -26,6 +28,7 @@ use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::default_client::ORIGINATOR;
use crate::git_info::collect_git_info;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -33,6 +36,19 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct SessionStateSnapshot {}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct SavedSession {
pub session: SessionMeta,
#[serde(default)]
pub items: Vec<ResponseItem>,
#[serde(default)]
pub state: SessionStateSnapshot,
pub session_id: ConversationId,
}
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
///

View File

@@ -10,6 +10,11 @@ use crate::openai_tools::ResponsesApiTool;
const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark");
#[derive(Serialize, Deserialize)]
pub(crate) struct ApplyPatchToolArgs {
pub(crate) input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ApplyPatchToolType {

View File

@@ -67,10 +67,6 @@ pub struct Cli {
#[arg(long = "json", default_value_t = false)]
pub json: bool,
/// Whether to include the plan tool in the conversation.
#[arg(long = "include-plan-tool", default_value_t = false)]
pub include_plan_tool: bool,
/// Specifies file where the last message from the agent should be written.
#[arg(long = "output-last-message")]
pub last_message_file: Option<PathBuf>,

View File

@@ -539,37 +539,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event;
// Header
ts_println!(self, "{}", "Plan update".style(self.magenta));
// Optional explanation
if let Some(explanation) = explanation
&& !explanation.trim().is_empty()
{
ts_println!(self, "{}", explanation.style(self.italic));
}
// Pretty-print the plan items with simple status markers.
for item in plan {
use codex_core::plan_tool::StepStatus;
match item.status {
StepStatus::Completed => {
ts_println!(self, " {} {}", "".style(self.green), item.step);
}
StepStatus::InProgress => {
ts_println!(self, " {} {}", "".style(self.cyan), item.step);
}
StepStatus::Pending => {
ts_println!(
self,
" {} {}",
"".style(self.dimmed),
item.step.style(self.dimmed)
);
}
}
}
ts_println!(self, "explanation: {explanation:?}");
ts_println!(self, "plan: {plan:?}");
}
EventMsg::GetHistoryEntryResponse(_) => {
// Currently ignored in exec output.

View File

@@ -53,7 +53,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
sandbox_mode: sandbox_mode_cli_arg,
prompt,
output_schema: output_schema_path,
include_plan_tool,
config_overrides,
} = cli;
@@ -162,7 +161,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
model_provider,
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: Some(include_plan_tool),
include_plan_tool: None,
include_apply_patch_tool: None,
include_view_image_tool: None,
show_raw_agent_reasoning: oss.then_some(true),

View File

@@ -1,3 +1,3 @@
[toolchain]
channel = "1.90.0"
components = ["clippy", "rustfmt", "rust-src"]
channel = "1.89.0"
components = [ "clippy", "rustfmt", "rust-src"]

View File

@@ -77,9 +77,11 @@ use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::history_cell::RateLimitSnapshotDisplay;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::status::new_status_output;
use crate::status::rate_limit_snapshot_display;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
// streaming internals are provided by crate::streaming and crate::markdown_stream
@@ -382,7 +384,7 @@ impl ChatWidget {
snapshot.primary.as_ref().map(|window| window.used_percent),
);
let display = history_cell::rate_limit_snapshot_display(&snapshot, Local::now());
let display = rate_limit_snapshot_display(&snapshot, Local::now());
self.rate_limit_snapshot = Some(display);
if !warnings.is_empty() {
@@ -1464,7 +1466,7 @@ impl ChatWidget {
default_usage = TokenUsage::default();
&default_usage
};
self.add_to_history(history_cell::new_status_output(
self.add_to_history(new_status_output(
&self.config,
usage_ref,
&self.conversation_id,

View File

@@ -11,30 +11,17 @@ use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::config_types::ReasoningSummaryFormat;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::project_doc::discover_project_doc_paths;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::num_format::format_with_separators;
use codex_protocol::parse_command::ParsedCommand;
use image::DynamicImage;
use image::ImageReader;
@@ -51,7 +38,6 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
@@ -60,10 +46,6 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
const STATUS_LIMIT_BAR_EMPTY: &str = " ";
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
@@ -225,6 +207,12 @@ pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
}
impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
}
impl HistoryCell for PlainHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
@@ -636,9 +624,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
}
const TOOL_CALL_MAX_LINES: usize = 5;
const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 64; // Just an eyeballed value
fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
if width < 4 {
return None;
}
@@ -646,7 +634,7 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
Some(inner_width)
}
fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
let content_width = lines
.iter()
.map(|line| {
@@ -682,26 +670,29 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
out
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize>) -> String {
let formatted = if let Some(rel) = relativize_to_home(directory) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
}
} else {
directory.display().to_string()
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
if let Some(max_width) = max_width {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
return crate::text_formatting::center_truncate_path(&formatted, max_width);
}
}
formatted
}
fn pretty_provider_name(id: &str) -> String {
if id.eq_ignore_ascii_case("openai") {
"OpenAI".to_string()
} else {
title_case(id)
}
}
/// Return the emoji followed by a hair space (U+200A).
/// Using only the hair space avoids excessive padding after the emoji while
/// still providing a small visual gap across terminals.
@@ -827,30 +818,12 @@ impl SessionHeaderHistoryCell {
}
fn format_directory(&self, max_width: Option<usize>) -> String {
Self::format_directory_inner(&self.directory, max_width)
format_directory_display(&self.directory, max_width)
}
#[cfg(test)]
fn format_directory_inner(directory: &Path, max_width: Option<usize>) -> String {
let formatted = if let Some(rel) = relativize_to_home(directory) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
}
} else {
directory.display().to_string()
};
if let Some(max_width) = max_width {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
return crate::text_formatting::center_truncate_path(&formatted, max_width);
}
}
formatted
format_directory_display(directory, max_width)
}
fn reasoning_label(&self) -> Option<&'static str> {
@@ -871,7 +844,6 @@ impl HistoryCell for SessionHeaderHistoryCell {
let make_row = |spans: Vec<Span<'static>>| Line::from(spans);
// Title line rendered inside the box: ">_ OpenAI Codex (vX)"
let title_spans: Vec<Span<'static>> = vec![
Span::from(">_ ").dim(),
Span::from("OpenAI Codex").bold(),
@@ -924,6 +896,12 @@ pub(crate) struct CompositeHistoryCell {
parts: Vec<Box<dyn HistoryCell>>,
}
impl CompositeHistoryCell {
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
Self { parts }
}
}
impl HistoryCell for CompositeHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
@@ -1083,228 +1061,6 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
pub resets_at: Option<String>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_in_seconds
.and_then(|seconds| i64::try_from(seconds).ok())
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
.map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string());
Self {
used_percent: window.used_percent,
resets_at,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
}
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
primary: snapshot
.primary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
secondary: snapshot
.secondary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
}
}
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push("/status".magenta().into());
let config_entries = create_config_summary_entries(config);
let lookup = |k: &str| -> String {
config_entries
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
// 📂 Workspace
lines.push(vec![padded_emoji("📂").into(), "Workspace".bold()].into());
// Path (home-relative, e.g., ~/code/project)
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => {
let sep = std::path::MAIN_SEPARATOR;
format!("~{sep}{}", rel.display())
}
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
lines.push(vec![" • Path: ".into(), cwd_str.into()].into());
// Approval mode (as-is)
lines.push(vec![" • Approval Mode: ".into(), lookup("approval").into()].into());
// Sandbox (simplified name only)
let sandbox_name = match &config.sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
};
lines.push(vec![" • Sandbox: ".into(), sandbox_name.into()].into());
// AGENTS.md files discovered via core's project_doc logic
let agents_list = {
match discover_project_doc_paths(config) {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let display = if let Some(parent) = p.parent() {
if parent == config.cwd {
"AGENTS.md".to_string()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
stripped.display().to_string()
} else {
p.display().to_string()
}
}
} else {
p.display().to_string()
};
rels.push(display);
}
rels
}
Err(_) => Vec::new(),
}
};
if agents_list.is_empty() {
lines.push(" • AGENTS files: (none)".into());
} else {
lines.push(vec![" • AGENTS files: ".into(), agents_list.join(", ").into()].into());
}
lines.push("".into());
// 👤 Account (only if ChatGPT tokens exist), shown under the first block
let auth_file = get_auth_file(&config.codex_home);
let auth = try_read_auth_json(&auth_file).ok();
let is_chatgpt_auth = auth
.as_ref()
.and_then(|auth| auth.tokens.as_ref())
.is_some();
if is_chatgpt_auth
&& let Some(auth) = auth.as_ref()
&& let Some(tokens) = auth.tokens.clone()
{
lines.push(vec![padded_emoji("👤").into(), "Account".bold()].into());
lines.push(" • Signed in with ChatGPT".into());
let info = tokens.id_token;
if let Some(email) = &info.email {
lines.push(vec![" • Login: ".into(), email.clone().into()].into());
}
match auth.openai_api_key.as_deref() {
Some(key) if !key.is_empty() => {
lines.push(" • Using API key. Run codex login to use ChatGPT plan".into());
}
_ => {
let plan_text = info
.get_chatgpt_plan_type()
.map(|s| title_case(&s))
.unwrap_or_else(|| "Unknown".to_string());
lines.push(vec![" • Plan: ".into(), plan_text.into()].into());
}
}
lines.push("".into());
}
// 🧠 Model
lines.push(vec![padded_emoji("🧠").into(), "Model".bold()].into());
lines.push(vec![" • Name: ".into(), config.model.clone().into()].into());
let provider_disp = pretty_provider_name(&config.model_provider_id);
lines.push(vec![" • Provider: ".into(), provider_disp.into()].into());
// Only show Reasoning fields if present in config summary
let reff = lookup("reasoning effort");
if !reff.is_empty() {
lines.push(vec![" • Reasoning Effort: ".into(), title_case(&reff).into()].into());
}
let rsum = lookup("reasoning summaries");
if !rsum.is_empty() {
lines.push(vec![" • Reasoning Summaries: ".into(), title_case(&rsum).into()].into());
}
lines.push("".into());
// 💻 Client
let cli_version = crate::version::CODEX_CLI_VERSION;
lines.push(vec![padded_emoji("💻").into(), "Client".bold()].into());
lines.push(vec![" • CLI Version: ".into(), cli_version.into()].into());
lines.push("".into());
// 📊 Token Usage
lines.push(vec!["📊 ".into(), "Token Usage".bold()].into());
if let Some(session_id) = session_id {
lines.push(vec![" • Session ID: ".into(), session_id.to_string().into()].into());
}
// Input: <input> [+ <cached> cached]
let mut input_line_spans: Vec<Span<'static>> = vec![
" • Input: ".into(),
format_with_separators(usage.non_cached_input()).into(),
];
if usage.cached_input_tokens > 0 {
let cached = usage.cached_input_tokens;
input_line_spans.push(format!(" (+ {cached} cached)").into());
}
lines.push(Line::from(input_line_spans));
// Output: <output>
lines.push(Line::from(vec![
" • Output: ".into(),
format_with_separators(usage.output_tokens).into(),
]));
// Total: <total>
lines.push(Line::from(vec![
" • Total: ".into(),
format_with_separators(usage.blended_total()).into(),
]));
if is_chatgpt_auth {
lines.push("".into());
lines.extend(build_status_limit_lines(rate_limits));
}
PlainHistoryCell { lines }
}
/// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
@@ -1659,82 +1415,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
invocation_spans.into()
}
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotDisplay>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> =
vec![vec![padded_emoji("⏱️").into(), "Usage Limits".bold()].into()];
match snapshot {
Some(snapshot) => {
let mut windows: Vec<(&str, &RateLimitWindowDisplay)> = Vec::new();
if let Some(primary) = snapshot.primary.as_ref() {
windows.push(("5h limit", primary));
}
if let Some(secondary) = snapshot.secondary.as_ref() {
windows.push(("Weekly limit", secondary));
}
if windows.is_empty() {
lines.push(" • No rate limit data available.".into());
} else {
let label_width = windows
.iter()
.map(|(label, _)| UnicodeWidthStr::width(*label))
.max()
.unwrap_or(0);
for (label, window) in windows {
lines.push(build_status_limit_line(
label,
window.used_percent,
label_width,
));
if let Some(resets_at) = window.resets_at.as_deref() {
lines.push(build_status_reset_line(resets_at));
}
}
}
}
None => lines.push(" • Send a message to load usage data.".into()),
}
lines
}
fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -> Line<'static> {
let clamped_percent = percent_used.clamp(0.0, 100.0);
let progress = render_status_limit_progress_bar(clamped_percent);
let summary = format_status_limit_summary(clamped_percent);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(5);
let padded_label = format!("{label:<label_width$}");
spans.push(format!("{padded_label}: ").into());
spans.push(progress.into());
spans.push(" ".into());
spans.push(summary.into());
Line::from(spans)
}
fn build_status_reset_line(resets_at: &str) -> Line<'static> {
vec![" ".into(), format!("Resets at: {resets_at}").dim()].into()
}
fn render_status_limit_progress_bar(percent_used: f64) -> String {
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
)
}
fn format_status_limit_summary(percent_used: f64) -> String {
format!("{percent_used:.0}% used")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2214,7 +1894,7 @@ mod tests {
// Long explanation forces wrapping; include long step text to verify step wrapping and alignment.
let update = UpdatePlanArgs {
explanation: Some(
"Ill update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
"I'll update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
.to_string(),
),
plan: vec![

View File

@@ -60,6 +60,7 @@ mod resume_picker;
mod session_log;
mod shimmer;
mod slash_command;
mod status;
mod status_indicator_widget;
mod streaming;
mod text_formatting;

View File

@@ -3,7 +3,7 @@ source: tui/src/history_cell.rs
expression: rendered
---
• Updated Plan
└ Ill update Grafana call
└ I'll update Grafana call
error handling by adding
retries and clearer
messages when the backend is

View File

@@ -0,0 +1,21 @@
---
source: tui/src/status.rs
expression: sanitized
---
/status
╭──────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning High, summaries Detailed) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: workspace-write │
│ Agents.md: <none> │
│ │
│ Token Usage: 1.9K (1K input + 900 output) + 200 cached │
│ 5h Limit : [███████████████░░░░░] 72% used │
│ Resets at: Jan 2, 2024 3:14 AM │
│ Weekly Limit: [█████████░░░░░░░░░░░] 45% used │
│ Resets at: Jan 2, 2024 3:24 AM │
╰──────────────────────────────────────────────────────────╯

696
codex-rs/tui/src/status.rs Normal file
View File

@@ -0,0 +1,696 @@
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::SESSION_HEADER_MAX_INNER_WIDTH;
use crate::history_cell::card_inner_width;
use crate::history_cell::format_directory_display;
use crate::history_cell::with_border;
use crate::version::CODEX_CLI_VERSION;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use codex_common::create_config_summary_entries;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
use codex_protocol::mcp_protocol::ConversationId;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::convert::TryFrom;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
const STATUS_LIMIT_BAR_EMPTY: &str = "";
fn label_display(label: &str) -> String {
format!(" {label}: ")
}
fn label_span(label: &str) -> Span<'static> {
Span::from(label_display(label)).dim()
}
fn label_width(label: &str) -> usize {
UnicodeWidthStr::width(label_display(label).as_str())
}
fn status_header_spans() -> Vec<Span<'static>> {
vec![
Span::from(">_ ").dim(),
Span::from("OpenAI Codex").bold(),
Span::from(" ").dim(),
Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(),
]
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
pub resets_at: Option<String>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_in_seconds
.and_then(|seconds| i64::try_from(seconds).ok())
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
.map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string());
Self {
used_percent: window.used_percent,
resets_at,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
}
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
primary: snapshot
.primary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
secondary: snapshot
.secondary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
}
}
#[derive(Debug)]
struct StatusField {
label: &'static str,
value: Vec<Span<'static>>,
}
impl StatusField {
fn text(label: &'static str, value: impl Into<String>) -> Self {
Self {
label,
value: vec![Span::from(value.into())],
}
}
fn spans(label: &'static str, value: Vec<Span<'static>>) -> Self {
Self { label, value }
}
}
#[derive(Debug, Default)]
struct StatusRows {
lines: Vec<Line<'static>>,
}
impl StatusRows {
fn new() -> Self {
Self { lines: Vec::new() }
}
fn push_blank(&mut self) {
self.lines.push(Line::from(Vec::<Span<'static>>::new()));
}
fn push_line(&mut self, spans: Vec<Span<'static>>) {
self.lines.push(Line::from(spans));
}
fn push_field(&mut self, field: StatusField) {
let mut spans = Vec::with_capacity(field.value.len() + 1);
spans.push(label_span(field.label));
spans.extend(field.value);
self.lines.push(Line::from(spans));
}
fn extend_fields<I>(&mut self, fields: I)
where
I: IntoIterator<Item = StatusField>,
{
for field in fields {
self.push_field(field);
}
}
fn extend_lines<I>(&mut self, lines: I)
where
I: IntoIterator<Item = Line<'static>>,
{
self.lines.extend(lines);
}
fn into_lines(self) -> Vec<Line<'static>> {
self.lines
}
}
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(config, usage, session_id, rate_limits);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
}
#[derive(Debug, Clone)]
struct StatusTokenUsageData {
total: u64,
input: u64,
cached_input: u64,
output: u64,
}
#[derive(Debug, Clone)]
enum StatusAccountDisplay {
ChatGpt {
email: Option<String>,
plan: Option<String>,
},
ApiKey,
}
#[derive(Debug, Clone)]
struct StatusRateLimitRow {
label: String,
percent_used: f64,
resets_at: Option<String>,
}
#[derive(Debug, Clone)]
enum StatusRateLimitData {
Available(Vec<StatusRateLimitRow>),
Missing,
}
#[derive(Debug)]
struct StatusHistoryCell {
model_name: String,
model_details: Vec<String>,
directory: PathBuf,
approval: String,
sandbox: String,
agents_summary: String,
account: Option<StatusAccountDisplay>,
session_id: Option<String>,
token_usage: StatusTokenUsageData,
rate_limits: StatusRateLimitData,
}
impl StatusHistoryCell {
fn new(
config: &Config,
usage: &TokenUsage,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> Self {
let config_entries = create_config_summary_entries(config);
let (model_name, model_details) = compose_model_display(config, &config_entries);
let approval = config_entries
.iter()
.find(|(k, _)| *k == "approval")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let sandbox = match &config.sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
};
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(config);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
let token_usage = StatusTokenUsageData {
total: usage.blended_total(),
input: usage.non_cached_input(),
cached_input: usage.cached_input_tokens,
output: usage.output_tokens,
};
let rate_limits = compose_rate_limit_data(rate_limits);
Self {
model_name,
model_details,
directory: config.cwd.clone(),
approval,
sandbox,
agents_summary,
account,
session_id,
token_usage,
rate_limits,
}
}
fn primary_fields(&self, inner_width: usize) -> Vec<StatusField> {
let mut fields = Vec::new();
let mut model_spans = vec![Span::from(self.model_name.clone())];
if !self.model_details.is_empty() {
model_spans.push(Span::from(" (").dim());
model_spans.push(Span::from(self.model_details.join(", ")).dim());
model_spans.push(Span::from(")").dim());
}
fields.push(StatusField::spans("Model", model_spans));
let directory_width = inner_width.saturating_sub(label_width("Directory"));
let directory = format_directory_display(&self.directory, Some(directory_width));
fields.push(StatusField::text("Directory", directory));
fields.push(StatusField::text("Approval", self.approval.clone()));
fields.push(StatusField::text("Sandbox", self.sandbox.clone()));
fields.push(StatusField::text("Agents.md", self.agents_summary.clone()));
fields
}
fn account_field(&self) -> Option<StatusField> {
let account = self.account.as_ref()?;
let value = match account {
StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) {
(Some(email), Some(plan)) => format!("{email} ({plan})"),
(Some(email), None) => email.clone(),
(None, Some(plan)) => plan.clone(),
(None, None) => "ChatGPT".to_string(),
},
StatusAccountDisplay::ApiKey => {
"API key configured (run codex login to use ChatGPT)".to_string()
}
};
Some(StatusField::text("Account", value))
}
fn session_field(&self) -> Option<StatusField> {
self.session_id
.as_ref()
.map(|session| StatusField::text("Session", session.clone()))
}
fn token_usage_field(&self) -> StatusField {
StatusField::spans("Token Usage", self.token_usage_spans())
}
fn token_usage_spans(&self) -> Vec<Span<'static>> {
let total_fmt = format_tokens_compact(self.token_usage.total);
let input_fmt = format_tokens_compact(self.token_usage.input);
let output_fmt = format_tokens_compact(self.token_usage.output);
let mut spans: Vec<Span<'static>> = vec![
Span::from(total_fmt),
Span::from(" (").dim(),
Span::from(input_fmt),
Span::from(" input").dim(),
Span::from(" + ").dim(),
Span::from(output_fmt),
Span::from(" output").dim(),
Span::from(")").dim(),
];
if self.token_usage.cached_input > 0 {
let cached_fmt = format_tokens_compact(self.token_usage.cached_input);
spans.push(Span::from(" + ").dim());
spans.push(Span::from(format!("{cached_fmt} cached")).dim());
}
spans
}
fn rate_limit_lines(&self) -> Vec<Line<'static>> {
match &self.rate_limits {
StatusRateLimitData::Available(rows_data) => {
if rows_data.is_empty() {
return vec![Line::from(vec![
label_span("Limits"),
Span::from("data not available yet").dim(),
])];
}
let label_width = rows_data
.iter()
.map(|row| UnicodeWidthStr::width(row.label.as_str()))
.max()
.unwrap_or(0);
let mut lines = Vec::new();
for row in rows_data {
let padded = format!("{label:<label_width$}", label = row.label);
lines.push(Line::from(vec![
Span::from(format!(" {padded}: ")).dim(),
Span::from(render_status_limit_progress_bar(row.percent_used)),
Span::from(" "),
Span::from(format_status_limit_summary(row.percent_used)),
]));
if let Some(resets_at) = row.resets_at.as_ref() {
lines.push(
vec![" ".into(), format!("Resets at: {resets_at}").dim()].into(),
);
}
}
lines
}
StatusRateLimitData::Missing => {
vec![Line::from(vec![
label_span("Limits"),
Span::from("data not available yet").dim(),
])]
}
}
}
}
impl HistoryCell for StatusHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
return Vec::new();
};
let mut rows = StatusRows::new();
rows.push_line(status_header_spans());
rows.push_blank();
rows.extend_fields(self.primary_fields(inner_width));
if let Some(account) = self.account_field() {
rows.push_field(account);
}
if let Some(session) = self.session_field() {
rows.push_field(session);
}
rows.push_blank();
rows.push_field(self.token_usage_field());
rows.extend_lines(self.rate_limit_lines());
with_border(rows.into_lines())
}
}
fn compose_model_display(config: &Config, entries: &[(&str, String)]) -> (String, Vec<String>) {
let mut details: Vec<String> = Vec::new();
if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") {
details.push(format!("reasoning {}", title_case(effort)));
}
if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") {
let summary = summary.trim();
if summary.is_empty() {
// nothing to add
} else if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") {
details.push("summaries off".to_string());
} else {
details.push(format!("summaries {}", title_case(summary)));
}
}
(config.model.clone(), details)
}
fn compose_agents_summary(config: &Config) -> String {
match discover_project_doc_paths(config) {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let display = if let Some(parent) = p.parent() {
if parent == config.cwd {
"AGENTS.md".to_string()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
stripped.display().to_string()
} else {
p.display().to_string()
}
}
} else {
p.display().to_string()
};
rels.push(display);
}
if rels.is_empty() {
"<none>".to_string()
} else {
rels.join(", ")
}
}
Err(_) => "<none>".to_string(),
}
}
fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
let auth_file = get_auth_file(&config.codex_home);
let auth = try_read_auth_json(&auth_file).ok()?;
if let Some(tokens) = auth.tokens.as_ref() {
let info = &tokens.id_token;
let email = info.email.clone();
let plan = info.get_chatgpt_plan_type().map(|p| title_case(p.as_str()));
return Some(StatusAccountDisplay::ChatGpt { email, plan });
}
if let Some(key) = auth.openai_api_key
&& !key.is_empty()
{
return Some(StatusAccountDisplay::ApiKey);
}
None
}
fn compose_rate_limit_data(snapshot: Option<&RateLimitSnapshotDisplay>) -> StatusRateLimitData {
match snapshot {
Some(snapshot) => {
let mut rows = Vec::new();
if let Some(primary) = snapshot.primary.as_ref() {
rows.push(StatusRateLimitRow {
label: "5h Limit".to_string(),
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
});
}
if let Some(secondary) = snapshot.secondary.as_ref() {
rows.push(StatusRateLimitRow {
label: "Weekly Limit".to_string(),
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
});
}
if rows.is_empty() {
StatusRateLimitData::Missing
} else {
StatusRateLimitData::Available(rows)
}
}
None => StatusRateLimitData::Missing,
}
}
fn format_tokens_compact(value: u64) -> String {
if value == 0 {
return "0".to_string();
}
if value < 1_000 {
return value.to_string();
}
let (scaled, suffix) = if value >= 1_000_000_000_000 {
(value as f64 / 1_000_000_000_000.0, "T")
} else if value >= 1_000_000_000 {
(value as f64 / 1_000_000_000.0, "B")
} else if value >= 1_000_000 {
(value as f64 / 1_000_000.0, "M")
} else {
(value as f64 / 1_000.0, "K")
};
let decimals = if scaled < 10.0 {
2
} else if scaled < 100.0 {
1
} else {
0
};
let mut formatted = format!("{scaled:.decimals$}");
if formatted.contains('.') {
while formatted.ends_with('0') {
formatted.pop();
}
if formatted.ends_with('.') {
formatted.pop();
}
}
format!("{formatted}{suffix}")
}
fn render_status_limit_progress_bar(percent_used: f64) -> String {
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
)
}
fn format_status_limit_summary(percent_used: f64) -> String {
format!("{percent_used:.0}% used")
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::protocol::TokenUsage;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use tempfile::TempDir;
fn test_config(temp_home: &TempDir) -> Config {
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_home.path().to_path_buf(),
)
.expect("load config")
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn status_snapshot_includes_reasoning_details() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model = "gpt-5-codex".to_string();
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_reasoning_summary = ReasoningSummary::Detailed;
config.sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let project_root = temp_home.path().join("workspace");
config.cwd = project_root;
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
};
let rate_snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_in_seconds: Some(600),
}),
secondary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(1_440),
resets_in_seconds: Some(1_200),
}),
};
let captured_at = Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single()
.expect("valid timestamp");
let rate_display = rate_limit_snapshot_display(&rate_snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let lines = composite.display_lines(80);
let mut rendered = render_lines(&lines).join("\n");
if cfg!(windows) {
rendered = rendered.replace('\\', "/");
}
let mut sanitized_lines: Vec<String> = Vec::new();
for line in rendered.lines() {
if let Some(pos) = line.find("Directory: ") {
if let Some(pipe_idx) = line.rfind('│') {
let prefix = &line[..pos + "Directory: ".len()];
let suffix = &line[pipe_idx..];
let content_width = pipe_idx.saturating_sub(pos + "Directory: ".len());
let replacement = "[[workspace]]";
let mut rebuilt = prefix.to_string();
rebuilt.push_str(replacement);
if content_width > replacement.len() {
rebuilt.push_str(&" ".repeat(content_width - replacement.len()));
}
rebuilt.push_str(suffix);
sanitized_lines.push(rebuilt);
continue;
}
}
sanitized_lines.push(line.to_string());
}
let sanitized = sanitized_lines.join("\n");
insta::assert_snapshot!(sanitized);
}
}

View File

@@ -1,16 +0,0 @@
[package]
name = "codex-utils-readiness"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["sync", "time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
[lints]
workspace = true