diff --git a/.github/codex-cli-login.png b/.github/codex-cli-login.png
deleted file mode 100644
index 0d4543ee1a..0000000000
Binary files a/.github/codex-cli-login.png and /dev/null differ
diff --git a/.github/codex-cli-permissions.png b/.github/codex-cli-permissions.png
deleted file mode 100644
index bb48e4a53b..0000000000
Binary files a/.github/codex-cli-permissions.png and /dev/null differ
diff --git a/.github/codex-cli-splash.png b/.github/codex-cli-splash.png
index 06e625ca4a..d0f50e55bd 100644
Binary files a/.github/codex-cli-splash.png and b/.github/codex-cli-splash.png differ
diff --git a/.github/demo.gif b/.github/demo.gif
deleted file mode 100644
index 12752744c6..0000000000
Binary files a/.github/demo.gif and /dev/null differ
diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml
index e01bc3881d..43e6992883 100644
--- a/.github/workflows/close-stale-contributor-prs.yml
+++ b/.github/workflows/close-stale-contributor-prs.yml
@@ -12,6 +12,8 @@ permissions:
jobs:
close-stale-contributor-prs:
+ # Prevent scheduled runs on forks
+ if: github.repository == 'openai/codex'
runs-on: ubuntu-latest
steps:
- name: Close inactive PRs from contributors
diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml
index c78b1f3161..4b417ae59d 100644
--- a/.github/workflows/issue-deduplicator.yml
+++ b/.github/workflows/issue-deduplicator.yml
@@ -9,7 +9,8 @@ on:
jobs:
gather-duplicates:
name: Identify potential duplicates
- if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') }}
+ # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
+ if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml
index 424c7e7263..da77812fec 100644
--- a/.github/workflows/issue-labeler.yml
+++ b/.github/workflows/issue-labeler.yml
@@ -9,7 +9,8 @@ on:
jobs:
gather-labels:
name: Generate label suggestions
- if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }}
+ # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
+ if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml
index b62a855055..c9f11f54fc 100644
--- a/.github/workflows/rust-release-prepare.yml
+++ b/.github/workflows/rust-release-prepare.yml
@@ -14,6 +14,8 @@ permissions:
jobs:
prepare:
+ # Prevent scheduled runs on forks (no secrets, wastes Actions minutes)
+ if: github.repository == 'openai/codex'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
diff --git a/README.md b/README.md
index 78eaf9eb35..eb4ace74f2 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,11 @@
npm i -g @openai/codex or brew install --cask codex
-
Codex CLI is a coding agent from OpenAI that runs locally on your computer.
-
-If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE
-If you are looking for the cloud-based agent from OpenAI, Codex Web , go to chatgpt.com/codex
-
-
+
+
+If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE.
+If you are looking for the cloud-based agent from OpenAI, Codex Web , go to chatgpt.com/codex .
---
@@ -15,25 +13,19 @@
### Installing and running Codex CLI
-Install globally with your preferred package manager. If you use npm:
+Install globally with your preferred package manager:
```shell
+# Install using npm
npm install -g @openai/codex
```
-Alternatively, if you use Homebrew:
-
```shell
+# Install using Homebrew
brew install --cask codex
```
-Then simply run `codex` to get started:
-
-```shell
-codex
-```
-
-If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-upgrade-codex-isnt-upgrading-me).
+Then simply run `codex` to get started.
You can also go to the latest GitHub Release and download the appropriate binary for your platform.
@@ -53,60 +45,15 @@ Each archive contains a single entry with the platform baked into the name (e.g.
### Using Codex with your ChatGPT plan
-
-
-
-
Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt).
-You can also use Codex with an API key, but this requires [additional setup](./docs/authentication.md#usage-based-billing-alternative-use-an-openai-api-key). If you previously used an API key for usage-based billing, see the [migration steps](./docs/authentication.md#migrating-from-usage-based-billing-api-key). If you're having trouble with login, please comment on [this issue](https://github.com/openai/codex/issues/1243).
+You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key).
-### Model Context Protocol (MCP)
+## Docs
-Codex can access MCP servers. To configure them, refer to the [config docs](./docs/config.md#mcp_servers).
-
-### Configuration
-
-Codex CLI supports a rich set of configuration options, with preferences stored in `~/.codex/config.toml`. For full configuration options, see [Configuration](./docs/config.md).
-
-### Execpolicy
-
-See the [Execpolicy quickstart](./docs/execpolicy.md) to set up rules that govern what commands Codex can execute.
-
-### Docs & FAQ
-
-- [**Getting started**](./docs/getting-started.md)
- - [CLI usage](./docs/getting-started.md#cli-usage)
- - [Slash Commands](./docs/slash_commands.md)
- - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input)
- - [Example prompts](./docs/getting-started.md#example-prompts)
- - [Custom prompts](./docs/prompts.md)
- - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)
-- [**Configuration**](./docs/config.md)
- - [Example config](./docs/example-config.md)
-- [**Sandbox & approvals**](./docs/sandbox.md)
-- [**Execpolicy quickstart**](./docs/execpolicy.md)
-- [**Authentication**](./docs/authentication.md)
- - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced)
- - [Login on a "Headless" machine](./docs/authentication.md#connecting-on-a-headless-machine)
-- **Automating Codex**
- - [GitHub Action](https://github.com/openai/codex-action)
- - [TypeScript SDK](./sdk/typescript/README.md)
- - [Non-interactive mode (`codex exec`)](./docs/exec.md)
-- [**Advanced**](./docs/advanced.md)
- - [Tracing / verbose logging](./docs/advanced.md#tracing--verbose-logging)
- - [Model Context Protocol (MCP)](./docs/advanced.md#model-context-protocol-mcp)
-- [**Zero data retention (ZDR)**](./docs/zdr.md)
+- [**Codex Documentation**](https://developers.openai.com/codex)
- [**Contributing**](./docs/contributing.md)
-- [**Install & build**](./docs/install.md)
- - [System Requirements](./docs/install.md#system-requirements)
- - [DotSlash](./docs/install.md#dotslash)
- - [Build from source](./docs/install.md#build-from-source)
-- [**FAQ**](./docs/faq.md)
+- [**Installing & building**](./docs/install.md)
- [**Open source fund**](./docs/open-source-fund.md)
----
-
-## License
-
This repository is licensed under the [Apache-2.0 License](LICENSE).
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5acd78c9f9..ab02e3a092 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -42,7 +42,7 @@ dependencies = [
"bitflags 2.10.0",
"bytes",
"bytestring",
- "derive_more 2.0.1",
+ "derive_more 2.1.1",
"encoding_rs",
"foldhash 0.1.5",
"futures-core",
@@ -137,7 +137,7 @@ dependencies = [
"bytes",
"bytestring",
"cfg-if",
- "derive_more 2.0.1",
+ "derive_more 2.1.1",
"encoding_rs",
"foldhash 0.1.5",
"futures-core",
@@ -912,9 +912,9 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.57"
+version = "4.5.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad"
+checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1"
dependencies = [
"clap",
]
@@ -1336,7 +1336,7 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.5",
- "toml_edit",
+ "toml_edit 0.24.0+spec-1.1.0",
"tracing",
"tracing-subscriber",
"tracing-test",
@@ -1433,7 +1433,7 @@ dependencies = [
"allocative",
"anyhow",
"clap",
- "derive_more 2.0.1",
+ "derive_more 2.1.1",
"env_logger",
"log",
"multimap",
@@ -1454,6 +1454,7 @@ dependencies = [
"codex-protocol",
"pretty_assertions",
"sentry",
+ "tracing",
"tracing-subscriber",
]
@@ -1610,7 +1611,6 @@ dependencies = [
"serde_json",
"strum_macros 0.27.2",
"tokio",
- "tonic",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -1733,7 +1733,7 @@ dependencies = [
"codex-windows-sandbox",
"color-eyre",
"crossterm",
- "derive_more 2.0.1",
+ "derive_more 2.1.1",
"diffy",
"dirs",
"dunce",
@@ -1806,7 +1806,7 @@ dependencies = [
"codex-windows-sandbox",
"color-eyre",
"crossterm",
- "derive_more 2.0.1",
+ "derive_more 2.1.1",
"diffy",
"dirs",
"dunce",
@@ -1821,6 +1821,7 @@ dependencies = [
"pulldown-cmark",
"rand 0.9.2",
"ratatui",
+ "ratatui-core",
"ratatui-macros",
"regex-lite",
"reqwest",
@@ -1842,6 +1843,7 @@ dependencies = [
"tracing-subscriber",
"tree-sitter-bash",
"tree-sitter-highlight",
+ "tui-scrollbar",
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
@@ -2006,6 +2008,20 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "compact_str"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -2027,6 +2043,18 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "const-hex"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "proptest",
+ "serde_core",
+]
+
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -2038,9 +2066,9 @@ dependencies = [
[[package]]
name = "convert_case"
-version = "0.7.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
@@ -2427,11 +2455,11 @@ dependencies = [
[[package]]
name = "derive_more"
-version = "2.0.1"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
- "derive_more-impl 2.0.1",
+ "derive_more-impl 2.1.1",
]
[[package]]
@@ -2449,13 +2477,14 @@ dependencies = [
[[package]]
name = "derive_more-impl"
-version = "2.0.1"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
- "convert_case 0.7.1",
+ "convert_case 0.10.0",
"proc-macro2",
"quote",
+ "rustc_version",
"syn 2.0.104",
"unicode-xid",
]
@@ -2571,6 +2600,15 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -3737,13 +3775,14 @@ dependencies = [
[[package]]
name = "insta"
-version = "1.44.3"
+version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698"
+checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5"
dependencies = [
"console",
"once_cell",
"similar",
+ "tempfile",
]
[[package]]
@@ -3768,17 +3807,6 @@ dependencies = [
"rustversion",
]
-[[package]]
-name = "io-uring"
-version = "0.7.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
-dependencies = [
- "bitflags 2.10.0",
- "cfg-if",
- "libc",
-]
-
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3907,6 +3935,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "kasuari"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
+dependencies = [
+ "hashbrown 0.16.0",
+ "thiserror 2.0.17",
+]
+
[[package]]
name = "keyring"
version = "3.6.3"
@@ -4052,6 +4090,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "local-waker"
version = "0.1.4"
@@ -4673,9 +4717,9 @@ dependencies = [
[[package]]
name = "opentelemetry"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6"
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
dependencies = [
"futures-core",
"futures-sink",
@@ -4687,9 +4731,9 @@ dependencies = [
[[package]]
name = "opentelemetry-appender-tracing"
-version = "0.30.1"
+version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16"
+checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
dependencies = [
"opentelemetry",
"tracing",
@@ -4699,9 +4743,9 @@ dependencies = [
[[package]]
name = "opentelemetry-http"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d"
+checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
dependencies = [
"async-trait",
"bytes",
@@ -4712,9 +4756,9 @@ dependencies = [
[[package]]
name = "opentelemetry-otlp"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b"
+checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
dependencies = [
"http 1.3.1",
"opentelemetry",
@@ -4732,30 +4776,32 @@ dependencies = [
[[package]]
name = "opentelemetry-proto"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc"
+checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"base64",
- "hex",
+ "const-hex",
"opentelemetry",
"opentelemetry_sdk",
"prost",
"serde",
+ "serde_json",
"tonic",
+ "tonic-prost",
]
[[package]]
name = "opentelemetry-semantic-conventions"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2"
+checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846"
[[package]]
name = "opentelemetry_sdk"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b"
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
dependencies = [
"futures-channel",
"futures-executor",
@@ -4763,7 +4809,6 @@ dependencies = [
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
- "serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
@@ -5119,7 +5164,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
- "toml_edit",
+ "toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -5146,10 +5191,25 @@ dependencies = [
]
[[package]]
-name = "prost"
-version = "0.13.5"
+name = "proptest"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
+dependencies = [
+ "bitflags 2.10.0",
+ "num-traits",
+ "rand 0.9.2",
+ "rand_chacha 0.9.0",
+ "rand_xorshift",
+ "regex-syntax 0.8.5",
+ "unarray",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
dependencies = [
"bytes",
"prost-derive",
@@ -5157,9 +5217,9 @@ dependencies = [
[[package]]
name = "prost-derive"
-version = "0.13.5"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -5359,6 +5419,15 @@ dependencies = [
"getrandom 0.3.3",
]
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core 0.9.3",
+]
+
[[package]]
name = "ratatui"
version = "0.29.0"
@@ -5366,7 +5435,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#
dependencies = [
"bitflags 2.10.0",
"cassowary",
- "compact_str",
+ "compact_str 0.8.1",
"crossterm",
"indoc",
"instability",
@@ -5375,7 +5444,27 @@ dependencies = [
"paste",
"strum 0.26.3",
"unicode-segmentation",
- "unicode-truncate",
+ "unicode-truncate 1.1.0",
+ "unicode-width 0.2.1",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
+dependencies = [
+ "bitflags 2.10.0",
+ "compact_str 0.9.0",
+ "hashbrown 0.16.0",
+ "indoc",
+ "itertools 0.14.0",
+ "kasuari",
+ "lru 0.16.2",
+ "strum 0.27.2",
+ "thiserror 2.0.17",
+ "unicode-segmentation",
+ "unicode-truncate 2.0.0",
"unicode-width 0.2.1",
]
@@ -5464,9 +5553,9 @@ dependencies = [
[[package]]
name = "regex-lite"
-version = "0.1.7"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30"
+checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
[[package]]
name = "regex-syntax"
@@ -6542,6 +6631,9 @@ name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+dependencies = [
+ "strum_macros 0.27.2",
+]
[[package]]
name = "strum_macros"
@@ -6933,29 +7025,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.47.1"
+version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
+checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
- "backtrace",
"bytes",
- "io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "slab",
"socket2 0.6.1",
"tokio-macros",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.1",
]
[[package]]
name = "tokio-macros"
-version = "2.5.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -6984,9 +7073,9 @@ dependencies = [
[[package]]
name = "tokio-stream"
-version = "0.1.17"
+version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
@@ -7048,18 +7137,30 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "0.7.3"
+version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
-version = "0.23.7"
+version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d"
+checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
+dependencies = [
+ "indexmap 2.12.0",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.24.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e"
dependencies = [
"indexmap 2.12.0",
"toml_datetime",
@@ -7070,30 +7171,28 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.0.4"
+version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
+checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
-version = "1.0.4"
+version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
-version = "0.13.1"
+version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
+checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
dependencies = [
"async-trait",
- "axum",
"base64",
"bytes",
- "h2",
"http 1.3.1",
"http-body",
"http-body-util",
@@ -7102,9 +7201,8 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
- "prost",
"rustls-native-certs",
- "socket2 0.5.10",
+ "sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-stream",
@@ -7114,6 +7212,17 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tonic-prost"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67"
+dependencies = [
+ "bytes",
+ "prost",
+ "tonic",
+]
+
[[package]]
name = "tower"
version = "0.5.2"
@@ -7231,15 +7340,16 @@ dependencies = [
[[package]]
name = "tracing-opentelemetry"
-version = "0.31.0"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c"
+checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e"
dependencies = [
"js-sys",
- "once_cell",
"opentelemetry",
"opentelemetry_sdk",
+ "rustversion",
"smallvec",
+ "thiserror 2.0.17",
"tracing",
"tracing-core",
"tracing-log",
@@ -7249,9 +7359,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
-version = "0.3.20"
+version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
@@ -7370,6 +7480,16 @@ dependencies = [
"termcolor",
]
+[[package]]
+name = "tui-scrollbar"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd"
+dependencies = [
+ "document-features",
+ "ratatui-core",
+]
+
[[package]]
name = "typenum"
version = "1.18.0"
@@ -7396,6 +7516,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
[[package]]
name = "unicase"
version = "2.8.1"
@@ -7431,6 +7557,17 @@ dependencies = [
"unicode-width 0.1.14",
]
+[[package]]
+name = "unicode-truncate"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.2.1",
+]
+
[[package]]
name = "unicode-width"
version = "0.1.14"
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index d79e87acce..645d5aa210 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -145,7 +145,7 @@ ignore = "0.4.23"
image = { version = "^0.25.9", default-features = false }
include_dir = "0.7.4"
indexmap = "2.12.0"
-insta = "1.44.3"
+insta = "1.46.0"
itertools = "0.14.0"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"
@@ -160,12 +160,12 @@ notify = "8.2.0"
nucleo-matcher = "0.3.1"
once_cell = "1.20.2"
openssl-sys = "*"
-opentelemetry = "0.30.0"
-opentelemetry-appender-tracing = "0.30.0"
-opentelemetry-otlp = "0.30.0"
-opentelemetry-semantic-conventions = "0.30.0"
-opentelemetry_sdk = "0.30.0"
-tracing-opentelemetry = "0.31.0"
+opentelemetry = "0.31.0"
+opentelemetry-appender-tracing = "0.31.0"
+opentelemetry-otlp = "0.31.0"
+opentelemetry-semantic-conventions = "0.31.0"
+opentelemetry_sdk = "0.31.0"
+tracing-opentelemetry = "0.32.0"
os_info = "3.12.0"
owo-colors = "4.2.0"
path-absolutize = "3.1.1"
@@ -176,9 +176,10 @@ pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
rand = "0.9"
ratatui = "0.29.0"
+ratatui-core = "0.1.0"
ratatui-macros = "0.6.0"
regex = "1.12.2"
-regex-lite = "0.1.7"
+regex-lite = "0.1.8"
reqwest = "0.12"
rmcp = { version = "0.12.0", default-features = false }
schemars = "0.8.22"
@@ -206,20 +207,20 @@ thiserror = "2.0.17"
time = "0.3"
tiny_http = "0.12"
tokio = "1"
-tokio-stream = "0.1.17"
+tokio-stream = "0.1.18"
tokio-test = "0.4"
tokio-util = "0.7.16"
toml = "0.9.5"
-toml_edit = "0.23.5"
-tonic = "0.13.1"
+toml_edit = "0.24.0"
tracing = "0.1.43"
tracing-appender = "0.2.3"
-tracing-subscriber = "0.3.20"
+tracing-subscriber = "0.3.22"
tracing-test = "0.2.5"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
tree-sitter-highlight = "0.25.10"
ts-rs = "11"
+tui-scrollbar = "0.2.1"
uds_windows = "1.1.0"
unicode-segmentation = "1.12.0"
unicode-width = "0.2"
diff --git a/codex-rs/README.md b/codex-rs/README.md
index a3d1b82fb8..cbe1fe3779 100644
--- a/codex-rs/README.md
+++ b/codex-rs/README.md
@@ -15,8 +15,8 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl
## Documentation quickstart
-- First run with Codex? Follow the walkthrough in [`docs/getting-started.md`](../docs/getting-started.md) for prompts, keyboard shortcuts, and session management.
-- Already shipping with Codex and want deeper control? Jump to [`docs/advanced.md`](../docs/advanced.md) and the configuration reference at [`docs/config.md`](../docs/config.md).
+- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
+- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
## What's new in the Rust CLI
@@ -30,7 +30,7 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses
#### MCP client
-Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#mcp_servers) for details.
+Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
#### MCP server (experimental)
diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs
index 85ce73ab3f..7f09216eab 100644
--- a/codex-rs/app-server-protocol/src/protocol/v2.rs
+++ b/codex-rs/app-server-protocol/src/protocol/v2.rs
@@ -1274,6 +1274,8 @@ pub struct Turn {
pub struct TurnError {
pub message: String,
pub codex_error_info: Option,
+ #[serde(default)]
+ pub additional_details: Option,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md
index f22758182c..787ec398d3 100644
--- a/codex-rs/app-server/README.md
+++ b/codex-rs/app-server/README.md
@@ -302,7 +302,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
-- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`.
+- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`.
- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items.
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
@@ -352,7 +352,7 @@ There are additional item-specific events:
### Errors
-`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
+`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo?, additionalDetails? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs
index f7e4f709ee..ad0455a058 100644
--- a/codex-rs/app-server/src/bespoke_event_handling.rs
+++ b/codex-rs/app-server/src/bespoke_event_handling.rs
@@ -340,6 +340,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
+ additional_details: None,
};
handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await;
outgoing
@@ -357,6 +358,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
+ additional_details: ev.additional_details,
};
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
@@ -1340,6 +1342,7 @@ mod tests {
TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
+ additional_details: None,
},
&turn_summary_store,
)
@@ -1351,6 +1354,7 @@ mod tests {
Some(TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
+ additional_details: None,
})
);
Ok(())
@@ -1398,6 +1402,7 @@ mod tests {
TurnError {
message: "oops".to_string(),
codex_error_info: None,
+ additional_details: None,
},
&turn_summary_store,
)
@@ -1439,6 +1444,7 @@ mod tests {
TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
+ additional_details: None,
},
&turn_summary_store,
)
@@ -1467,6 +1473,7 @@ mod tests {
Some(TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
+ additional_details: None,
})
);
}
@@ -1691,6 +1698,7 @@ mod tests {
TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
+ additional_details: None,
},
&turn_summary_store,
)
@@ -1710,6 +1718,7 @@ mod tests {
TurnError {
message: "b1".to_string(),
codex_error_info: None,
+ additional_details: None,
},
&turn_summary_store,
)
@@ -1746,6 +1755,7 @@ mod tests {
Some(TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
+ additional_details: None,
})
);
}
@@ -1766,6 +1776,7 @@ mod tests {
Some(TurnError {
message: "b1".to_string(),
codex_error_info: None,
+ additional_details: None,
})
);
}
diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs
index 622672dc16..224e0da10b 100644
--- a/codex-rs/app-server/src/lib.rs
+++ b/codex-rs/app-server/src/lib.rs
@@ -17,13 +17,11 @@ use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use toml::Value as TomlValue;
-use tracing::Level;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
-use tracing_subscriber::filter::Targets;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -103,11 +101,8 @@ pub async fn run_main(
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env());
- let feedback_layer = tracing_subscriber::fmt::layer()
- .with_writer(feedback.make_writer())
- .with_ansi(false)
- .with_target(false)
- .with_filter(Targets::new().with_default(Level::TRACE));
+ let feedback_layer = feedback.logger_layer();
+ let feedback_metadata_layer = feedback.metadata_layer();
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
@@ -116,6 +111,7 @@ pub async fn run_main(
let _ = tracing_subscriber::registry()
.with(stderr_fmt)
.with(feedback_layer)
+ .with(feedback_metadata_layer)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
diff --git a/codex-rs/cli/tests/execpolicy.rs b/codex-rs/cli/tests/execpolicy.rs
index 7d8a2b1c45..13c614be1b 100644
--- a/codex-rs/cli/tests/execpolicy.rs
+++ b/codex-rs/cli/tests/execpolicy.rs
@@ -59,3 +59,61 @@ prefix_rule(
Ok(())
}
+
+#[test]
+fn execpolicy_check_includes_forbidden_reason_when_present()
+-> Result<(), Box> {
+ let codex_home = TempDir::new()?;
+ let policy_path = codex_home.path().join("rules").join("policy.rules");
+ fs::create_dir_all(
+ policy_path
+ .parent()
+ .expect("policy path should have a parent"),
+ )?;
+ fs::write(
+ &policy_path,
+ r#"
+prefix_rule(
+ pattern = ["git", "push"],
+ decision = "forbidden",
+ forbidden_reason = "pushing is blocked in this repo",
+)
+"#,
+ )?;
+
+ let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
+ .env("CODEX_HOME", codex_home.path())
+ .args([
+ "execpolicy",
+ "check",
+ "--rules",
+ policy_path
+ .to_str()
+ .expect("policy path should be valid UTF-8"),
+ "git",
+ "push",
+ "origin",
+ "main",
+ ])
+ .output()?;
+
+ assert!(output.status.success());
+ let result: serde_json::Value = serde_json::from_slice(&output.stdout)?;
+ assert_eq!(
+ result,
+ json!({
+ "decision": "forbidden",
+ "matchedRules": [
+ {
+ "prefixRuleMatch": {
+ "matchedPrefix": ["git", "push"],
+ "decision": "forbidden",
+ "forbiddenReason": "pushing is blocked in this repo"
+ }
+ }
+ ]
+ })
+ );
+
+ Ok(())
+}
diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs
index 19e82de332..db1524d270 100644
--- a/codex-rs/codex-api/src/common.rs
+++ b/codex-rs/codex-api/src/common.rs
@@ -59,6 +59,7 @@ pub enum ResponseEvent {
summary_index: i64,
},
RateLimits(RateLimitSnapshot),
+ ModelsEtag(String),
}
#[derive(Debug, Serialize, Clone)]
diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs
index 4ad133dda4..b7fa0572f0 100644
--- a/codex-rs/codex-api/src/endpoint/chat.rs
+++ b/codex-rs/codex-api/src/endpoint/chat.rs
@@ -152,6 +152,9 @@ impl Stream for AggregatedStream {
Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot))));
}
+ Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => {
+ return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag))));
+ }
Poll::Ready(Some(Ok(ResponseEvent::Completed {
response_id,
token_usage,
diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs
index cb43f4ff5b..74e109bf2a 100644
--- a/codex-rs/codex-api/src/endpoint/models.rs
+++ b/codex-rs/codex-api/src/endpoint/models.rs
@@ -5,6 +5,7 @@ use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestTelemetry;
+use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use http::HeaderMap;
use http::Method;
@@ -41,7 +42,7 @@ impl ModelsClient {
&self,
client_version: &str,
extra_headers: HeaderMap,
- ) -> Result {
+ ) -> Result<(Vec, Option), ApiError> {
let builder = || {
let mut req = self.provider.build_request(Method::GET, self.path());
req.headers.extend(extra_headers.clone());
@@ -66,7 +67,7 @@ impl ModelsClient {
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
- let ModelsResponse { models, etag } = serde_json::from_slice::(&resp.body)
+ let ModelsResponse { models } = serde_json::from_slice::(&resp.body)
.map_err(|e| {
ApiError::Stream(format!(
"failed to decode models response: {e}; body: {}",
@@ -74,9 +75,7 @@ impl ModelsClient {
))
})?;
- let etag = header_etag.unwrap_or(etag);
-
- Ok(ModelsResponse { models, etag })
+ Ok((models, header_etag))
}
}
@@ -102,16 +101,15 @@ mod tests {
struct CapturingTransport {
last_request: Arc>>,
body: Arc,
+ etag: Option,
}
impl Default for CapturingTransport {
fn default() -> Self {
Self {
last_request: Arc::new(Mutex::new(None)),
- body: Arc::new(ModelsResponse {
- models: Vec::new(),
- etag: String::new(),
- }),
+ body: Arc::new(ModelsResponse { models: Vec::new() }),
+ etag: None,
}
}
}
@@ -122,8 +120,8 @@ mod tests {
*self.last_request.lock().unwrap() = Some(req);
let body = serde_json::to_vec(&*self.body).unwrap();
let mut headers = HeaderMap::new();
- if !self.body.etag.is_empty() {
- headers.insert(ETAG, self.body.etag.parse().unwrap());
+ if let Some(etag) = &self.etag {
+ headers.insert(ETAG, etag.parse().unwrap());
}
Ok(Response {
status: StatusCode::OK,
@@ -166,14 +164,12 @@ mod tests {
#[tokio::test]
async fn appends_client_version_query() {
- let response = ModelsResponse {
- models: Vec::new(),
- etag: String::new(),
- };
+ let response = ModelsResponse { models: Vec::new() };
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
+ etag: None,
};
let client = ModelsClient::new(
@@ -182,12 +178,12 @@ mod tests {
DummyAuth,
);
- let result = client
+ let (models, _) = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
- assert_eq!(result.models.len(), 0);
+ assert_eq!(models.len(), 0);
let url = transport
.last_request
@@ -231,12 +227,12 @@ mod tests {
}))
.unwrap(),
],
- etag: String::new(),
};
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
+ etag: None,
};
let client = ModelsClient::new(
@@ -245,27 +241,25 @@ mod tests {
DummyAuth,
);
- let result = client
+ let (models, _) = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
- assert_eq!(result.models.len(), 1);
- assert_eq!(result.models[0].slug, "gpt-test");
- assert_eq!(result.models[0].supported_in_api, true);
- assert_eq!(result.models[0].priority, 1);
+ assert_eq!(models.len(), 1);
+ assert_eq!(models[0].slug, "gpt-test");
+ assert_eq!(models[0].supported_in_api, true);
+ assert_eq!(models[0].priority, 1);
}
#[tokio::test]
async fn list_models_includes_etag() {
- let response = ModelsResponse {
- models: Vec::new(),
- etag: "\"abc\"".to_string(),
- };
+ let response = ModelsResponse { models: Vec::new() };
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
+ etag: Some("\"abc\"".to_string()),
};
let client = ModelsClient::new(
@@ -274,12 +268,12 @@ mod tests {
DummyAuth,
);
- let result = client
+ let (models, etag) = client
.list_models("0.1.0", HeaderMap::new())
.await
.expect("request should succeed");
- assert_eq!(result.models.len(), 0);
- assert_eq!(result.etag, "\"abc\"");
+ assert_eq!(models.len(), 0);
+ assert_eq!(etag, Some("\"abc\"".to_string()));
}
}
diff --git a/codex-rs/codex-api/src/requests/chat.rs b/codex-rs/codex-api/src/requests/chat.rs
index d5ac188efc..60f450ca0d 100644
--- a/codex-rs/codex-api/src/requests/chat.rs
+++ b/codex-rs/codex-api/src/requests/chat.rs
@@ -204,24 +204,16 @@ impl<'a> ChatRequestBuilder<'a> {
call_id,
..
} => {
- let mut msg = json!({
- "role": "assistant",
- "content": null,
- "tool_calls": [{
- "id": call_id,
- "type": "function",
- "function": {
- "name": name,
- "arguments": arguments,
- }
- }]
+ let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
+ let tool_call = json!({
+ "id": call_id,
+ "type": "function",
+ "function": {
+ "name": name,
+ "arguments": arguments,
+ }
});
- if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
- && let Some(obj) = msg.as_object_mut()
- {
- obj.insert("reasoning".to_string(), json!(reasoning));
- }
- messages.push(msg);
+ push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::LocalShellCall {
id,
@@ -229,22 +221,14 @@ impl<'a> ChatRequestBuilder<'a> {
status,
action,
} => {
- let mut msg = json!({
- "role": "assistant",
- "content": null,
- "tool_calls": [{
- "id": id.clone().unwrap_or_default(),
- "type": "local_shell_call",
- "status": status,
- "action": action,
- }]
+ let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
+ let tool_call = json!({
+ "id": id.clone().unwrap_or_default(),
+ "type": "local_shell_call",
+ "status": status,
+ "action": action,
});
- if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
- && let Some(obj) = msg.as_object_mut()
- {
- obj.insert("reasoning".to_string(), json!(reasoning));
- }
- messages.push(msg);
+ push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::FunctionCallOutput { call_id, output } => {
let content_value = if let Some(items) = &output.content_items {
@@ -277,18 +261,16 @@ impl<'a> ChatRequestBuilder<'a> {
input,
status: _,
} => {
- messages.push(json!({
- "role": "assistant",
- "content": null,
- "tool_calls": [{
- "id": id,
- "type": "custom",
- "custom": {
- "name": name,
- "input": input,
- }
- }]
- }));
+ let tool_call = json!({
+ "id": id,
+ "type": "custom",
+ "custom": {
+ "name": name,
+ "input": input,
+ }
+ });
+ let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
+ push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
messages.push(json!({
@@ -328,11 +310,50 @@ impl<'a> ChatRequestBuilder<'a> {
}
}
+fn push_tool_call_message(messages: &mut Vec, tool_call: Value, reasoning: Option<&str>) {
+ // Chat Completions requires that tool calls are grouped into a single assistant message
+ // (with `tool_calls: [...]`) followed by tool role responses.
+ if let Some(Value::Object(obj)) = messages.last_mut()
+ && obj.get("role").and_then(Value::as_str) == Some("assistant")
+ && obj.get("content").is_some_and(Value::is_null)
+ && let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut)
+ {
+ tool_calls.push(tool_call);
+ if let Some(reasoning) = reasoning {
+ if let Some(Value::String(existing)) = obj.get_mut("reasoning") {
+ if !existing.is_empty() {
+ existing.push('\n');
+ }
+ existing.push_str(reasoning);
+ } else {
+ obj.insert(
+ "reasoning".to_string(),
+ Value::String(reasoning.to_string()),
+ );
+ }
+ }
+ return;
+ }
+
+ let mut msg = json!({
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [tool_call],
+ });
+ if let Some(reasoning) = reasoning
+ && let Some(obj) = msg.as_object_mut()
+ {
+ obj.insert("reasoning".to_string(), json!(reasoning));
+ }
+ messages.push(msg);
+}
+
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
+ use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderValue;
@@ -385,4 +406,89 @@ mod tests {
Some(&HeaderValue::from_static("review"))
);
}
+
+ #[test]
+ fn groups_consecutive_tool_calls_into_a_single_assistant_message() {
+ let prompt_input = vec![
+ ResponseItem::Message {
+ id: None,
+ role: "user".to_string(),
+ content: vec![ContentItem::InputText {
+ text: "read these".to_string(),
+ }],
+ },
+ ResponseItem::FunctionCall {
+ id: None,
+ name: "read_file".to_string(),
+ arguments: r#"{"path":"a.txt"}"#.to_string(),
+ call_id: "call-a".to_string(),
+ },
+ ResponseItem::FunctionCall {
+ id: None,
+ name: "read_file".to_string(),
+ arguments: r#"{"path":"b.txt"}"#.to_string(),
+ call_id: "call-b".to_string(),
+ },
+ ResponseItem::FunctionCall {
+ id: None,
+ name: "read_file".to_string(),
+ arguments: r#"{"path":"c.txt"}"#.to_string(),
+ call_id: "call-c".to_string(),
+ },
+ ResponseItem::FunctionCallOutput {
+ call_id: "call-a".to_string(),
+ output: FunctionCallOutputPayload {
+ content: "A".to_string(),
+ ..Default::default()
+ },
+ },
+ ResponseItem::FunctionCallOutput {
+ call_id: "call-b".to_string(),
+ output: FunctionCallOutputPayload {
+ content: "B".to_string(),
+ ..Default::default()
+ },
+ },
+ ResponseItem::FunctionCallOutput {
+ call_id: "call-c".to_string(),
+ output: FunctionCallOutputPayload {
+ content: "C".to_string(),
+ ..Default::default()
+ },
+ },
+ ];
+
+ let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
+ .build(&provider())
+ .expect("request");
+
+ let messages = req
+ .body
+ .get("messages")
+ .and_then(|v| v.as_array())
+ .expect("messages array");
+ // system + user + assistant(tool_calls=[...]) + 3 tool outputs
+ assert_eq!(messages.len(), 6);
+
+ assert_eq!(messages[0]["role"], "system");
+ assert_eq!(messages[1]["role"], "user");
+
+ let tool_calls_msg = &messages[2];
+ assert_eq!(tool_calls_msg["role"], "assistant");
+ assert_eq!(tool_calls_msg["content"], serde_json::Value::Null);
+ let tool_calls = tool_calls_msg["tool_calls"]
+ .as_array()
+ .expect("tool_calls array");
+ assert_eq!(tool_calls.len(), 3);
+ assert_eq!(tool_calls[0]["id"], "call-a");
+ assert_eq!(tool_calls[1]["id"], "call-b");
+ assert_eq!(tool_calls[2]["id"], "call-c");
+
+ assert_eq!(messages[3]["role"], "tool");
+ assert_eq!(messages[3]["tool_call_id"], "call-a");
+ assert_eq!(messages[4]["role"], "tool");
+ assert_eq!(messages[4]["tool_call_id"], "call-b");
+ assert_eq!(messages[5]["role"], "tool");
+ assert_eq!(messages[5]["tool_call_id"], "call-c");
+ }
}
diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs
index 21adfa571a..dec35890b7 100644
--- a/codex-rs/codex-api/src/sse/chat.rs
+++ b/codex-rs/codex-api/src/sse/chat.rs
@@ -30,6 +30,21 @@ pub(crate) fn spawn_chat_stream(
ResponseStream { rx_event }
}
+/// Processes Server-Sent Events from the legacy Chat Completions streaming API.
+///
+/// The upstream protocol terminates a streaming response with a final sentinel event
+/// (`data: [DONE]`). Historically, some of our test stubs have emitted `data: DONE`
+/// (without brackets) instead.
+///
+/// `eventsource_stream` delivers these sentinels as regular events rather than signaling
+/// end-of-stream. If we try to parse them as JSON, we log and skip them, then keep
+/// polling for more events.
+///
+/// On servers that keep the HTTP connection open after emitting the sentinel (notably
+/// wiremock on Windows), skipping the sentinel means we never emit `ResponseEvent::Completed`.
+/// Higher-level workflows/tests that wait for completion before issuing subsequent model
+/// calls will then stall, which shows up as "expected N requests, got 1" verification
+/// failures in the mock server.
pub async fn process_chat_sse(
stream: S,
tx_event: mpsc::Sender>,
@@ -57,6 +72,31 @@ pub async fn process_chat_sse(
let mut reasoning_item: Option = None;
let mut completed_sent = false;
+ async fn flush_and_complete(
+ tx_event: &mpsc::Sender>,
+ reasoning_item: &mut Option,
+ assistant_item: &mut Option,
+ ) {
+ if let Some(reasoning) = reasoning_item.take() {
+ let _ = tx_event
+ .send(Ok(ResponseEvent::OutputItemDone(reasoning)))
+ .await;
+ }
+
+ if let Some(assistant) = assistant_item.take() {
+ let _ = tx_event
+ .send(Ok(ResponseEvent::OutputItemDone(assistant)))
+ .await;
+ }
+
+ let _ = tx_event
+ .send(Ok(ResponseEvent::Completed {
+ response_id: String::new(),
+ token_usage: None,
+ }))
+ .await;
+ }
+
loop {
let start = Instant::now();
let response = timeout(idle_timeout, stream.next()).await;
@@ -70,24 +110,8 @@ pub async fn process_chat_sse(
return;
}
Ok(None) => {
- if let Some(reasoning) = reasoning_item {
- let _ = tx_event
- .send(Ok(ResponseEvent::OutputItemDone(reasoning)))
- .await;
- }
-
- if let Some(assistant) = assistant_item {
- let _ = tx_event
- .send(Ok(ResponseEvent::OutputItemDone(assistant)))
- .await;
- }
if !completed_sent {
- let _ = tx_event
- .send(Ok(ResponseEvent::Completed {
- response_id: String::new(),
- token_usage: None,
- }))
- .await;
+ flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
}
return;
}
@@ -101,16 +125,25 @@ pub async fn process_chat_sse(
trace!("SSE event: {}", sse.data);
- if sse.data.trim().is_empty() {
+ let data = sse.data.trim();
+
+ if data.is_empty() {
continue;
}
- let value: serde_json::Value = match serde_json::from_str(&sse.data) {
+ if data == "[DONE]" || data == "DONE" {
+ if !completed_sent {
+ flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
+ }
+ return;
+ }
+
+ let value: serde_json::Value = match serde_json::from_str(data) {
Ok(val) => val,
Err(err) => {
debug!(
"Failed to parse ChatCompletions SSE event: {err}, data: {}",
- &sse.data
+ data
);
continue;
}
@@ -362,6 +395,16 @@ mod tests {
body
}
+ /// Regression test: the stream should complete when we see a `[DONE]` sentinel.
+ ///
+ /// This is important for tests/mocks that don't immediately close the underlying
+ /// connection after emitting the sentinel.
+ #[tokio::test]
+ async fn completes_on_done_sentinel_without_json() {
+ let events = collect_events("event: message\ndata: [DONE]\n\n").await;
+ assert_matches!(&events[..], [ResponseEvent::Completed { .. }]);
+ }
+
async fn collect_events(body: &str) -> Vec {
let reader = ReaderStream::new(std::io::Cursor::new(body.to_string()))
.map_err(|err| codex_client::TransportError::Network(err.to_string()));
diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs
index 5dbec7b77a..9d2a1be075 100644
--- a/codex-rs/codex-api/src/sse/responses.rs
+++ b/codex-rs/codex-api/src/sse/responses.rs
@@ -51,11 +51,19 @@ pub fn spawn_response_stream(
telemetry: Option>,
) -> ResponseStream {
let rate_limits = parse_rate_limit(&stream_response.headers);
+ let models_etag = stream_response
+ .headers
+ .get("X-Models-Etag")
+ .and_then(|v| v.to_str().ok())
+ .map(ToString::to_string);
let (tx_event, rx_event) = mpsc::channel::>(1600);
tokio::spawn(async move {
if let Some(snapshot) = rate_limits {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
}
+ if let Some(etag) = models_etag {
+ let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
+ }
process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await;
});
diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs
index f197e806fd..8ed6ce0d6d 100644
--- a/codex-rs/codex-api/tests/models_integration.rs
+++ b/codex-rs/codex-api/tests/models_integration.rs
@@ -86,7 +86,6 @@ async fn models_client_hits_models_endpoint() {
context_window: None,
experimental_supported_tools: Vec::new(),
}],
- etag: String::new(),
};
Mock::given(method("GET"))
@@ -102,13 +101,13 @@ async fn models_client_hits_models_endpoint() {
let transport = ReqwestTransport::new(reqwest::Client::new());
let client = ModelsClient::new(transport, provider(&base_url), DummyAuth);
- let result = client
+ let (models, _) = client
.list_models("0.1.0", HeaderMap::new())
.await
.expect("models request should succeed");
- assert_eq!(result.models.len(), 1);
- assert_eq!(result.models[0].slug, "gpt-test");
+ assert_eq!(models.len(), 1);
+ assert_eq!(models[0].slug, "gpt-test");
let received = server
.received_requests()
diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs
index 986ba3a679..abe6e29ee5 100644
--- a/codex-rs/codex-client/src/transport.rs
+++ b/codex-rs/codex-client/src/transport.rs
@@ -69,6 +69,15 @@ impl ReqwestTransport {
#[async_trait]
impl HttpTransport for ReqwestTransport {
async fn execute(&self, req: Request) -> Result {
+ if enabled!(Level::TRACE) {
+ trace!(
+ "{} to {}: {}",
+ req.method,
+ req.url,
+ req.body.as_ref().unwrap_or_default()
+ );
+ }
+
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 92ce74e3f5..996d156f4d 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -88,6 +88,7 @@ use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
+use crate::feedback_tags;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
@@ -246,7 +247,9 @@ impl Codex {
let config = Arc::new(config);
if config.features.enabled(Feature::RemoteModels)
- && let Err(err) = models_manager.refresh_available_models(&config).await
+ && let Err(err) = models_manager
+ .refresh_available_models_with_cache(&config)
+ .await
{
error!("failed to refresh available models: {err:?}");
}
@@ -808,6 +811,13 @@ impl Session {
.await;
}
+ // Seed usage info from the recorded rollout so UIs can show token counts
+ // immediately on resume/fork.
+ if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
+ let mut state = self.state.lock().await;
+ state.set_token_info(Some(info));
+ }
+
// If persisting, persist all rollout items as-is (recorder filters)
if persist && !rollout_items.is_empty() {
self.persist_rollout_items(&rollout_items).await;
@@ -818,6 +828,13 @@ impl Session {
}
}
+ fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option {
+ rollout_items.iter().rev().find_map(|item| match item {
+ RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
+ _ => None,
+ })
+ }
+
pub(crate) async fn update_settings(
&self,
updates: SessionSettingsUpdate,
@@ -1422,12 +1439,14 @@ impl Session {
message: impl Into,
codex_error: CodexErr,
) {
+ let additional_details = codex_error.to_string();
let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: codex_error.http_status_code_value(),
};
let event = EventMsg::StreamError(StreamErrorEvent {
message: message.into(),
codex_error_info: Some(codex_error_info),
+ additional_details: Some(additional_details),
});
self.send_event(turn_context, event).await;
}
@@ -2049,7 +2068,7 @@ mod handlers {
review_request: ReviewRequest,
) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
- match resolve_review_request(review_request, config.cwd.as_path()) {
+ match resolve_review_request(review_request, turn_context.cwd.as_path()) {
Ok(resolved) => {
spawn_review_thread(
Arc::clone(sess),
@@ -2521,6 +2540,15 @@ async fn try_run_turn(
truncation_policy: Some(turn_context.truncation_policy.into()),
});
+ feedback_tags!(
+ model = turn_context.client.get_model(),
+ approval_policy = turn_context.approval_policy,
+ sandbox_policy = turn_context.sandbox_policy,
+ effort = turn_context.client.get_reasoning_effort(),
+ auth_mode = sess.services.auth_manager.get_auth_mode(),
+ features = sess.features.enabled_features(),
+ );
+
sess.persist_rollout_items(&[rollout_item]).await;
let mut stream = turn_context
.client
@@ -2611,6 +2639,13 @@ async fn try_run_turn(
// token usage is available to avoid duplicate TokenCount events.
sess.update_rate_limits(&turn_context, snapshot).await;
}
+ ResponseEvent::ModelsEtag(etag) => {
+ // Update internal state with latest models etag
+ sess.services
+ .models_manager
+ .refresh_if_new_etag(etag, sess.features.enabled(Feature::RemoteModels))
+ .await;
+ }
ResponseEvent::Completed {
response_id: _,
token_usage,
@@ -2751,6 +2786,9 @@ mod tests {
use crate::protocol::RateLimitSnapshot;
use crate::protocol::RateLimitWindow;
use crate::protocol::ResumedHistory;
+ use crate::protocol::TokenCountEvent;
+ use crate::protocol::TokenUsage;
+ use crate::protocol::TokenUsageInfo;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
@@ -2805,6 +2843,83 @@ mod tests {
assert_eq!(expected, actual);
}
+ #[tokio::test]
+ async fn record_initial_history_seeds_token_info_from_rollout() {
+ let (session, turn_context) = make_session_and_context().await;
+ let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context);
+
+ let info1 = TokenUsageInfo {
+ total_token_usage: TokenUsage {
+ input_tokens: 10,
+ cached_input_tokens: 0,
+ output_tokens: 20,
+ reasoning_output_tokens: 0,
+ total_tokens: 30,
+ },
+ last_token_usage: TokenUsage {
+ input_tokens: 3,
+ cached_input_tokens: 0,
+ output_tokens: 4,
+ reasoning_output_tokens: 0,
+ total_tokens: 7,
+ },
+ model_context_window: Some(1_000),
+ };
+ let info2 = TokenUsageInfo {
+ total_token_usage: TokenUsage {
+ input_tokens: 100,
+ cached_input_tokens: 50,
+ output_tokens: 200,
+ reasoning_output_tokens: 25,
+ total_tokens: 375,
+ },
+ last_token_usage: TokenUsage {
+ input_tokens: 10,
+ cached_input_tokens: 0,
+ output_tokens: 20,
+ reasoning_output_tokens: 5,
+ total_tokens: 35,
+ },
+ model_context_window: Some(2_000),
+ };
+
+ rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
+ TokenCountEvent {
+ info: Some(info1),
+ rate_limits: None,
+ },
+ )));
+ rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
+ TokenCountEvent {
+ info: None,
+ rate_limits: None,
+ },
+ )));
+ rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
+ TokenCountEvent {
+ info: Some(info2.clone()),
+ rate_limits: None,
+ },
+ )));
+ rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
+ TokenCountEvent {
+ info: None,
+ rate_limits: None,
+ },
+ )));
+
+ session
+ .record_initial_history(InitialHistory::Resumed(ResumedHistory {
+ conversation_id: ConversationId::default(),
+ history: rollout_items,
+ rollout_path: PathBuf::from("/tmp/resume.jsonl"),
+ }))
+ .await;
+
+ let actual = session.state.lock().await.token_info();
+ assert_eq!(actual, Some(info2));
+ }
+
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;
@@ -3138,7 +3253,7 @@ mod tests {
exec_policy,
auth_manager: auth_manager.clone(),
otel_manager: otel_manager.clone(),
- models_manager,
+ models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
};
@@ -3225,7 +3340,7 @@ mod tests {
exec_policy,
auth_manager: Arc::clone(&auth_manager),
otel_manager: otel_manager.clone(),
- models_manager,
+ models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
};
diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs
index 9d60684b7a..a7e70ff234 100644
--- a/codex-rs/core/src/codex_delegate.rs
+++ b/codex-rs/core/src/codex_delegate.rs
@@ -184,6 +184,10 @@ async fn forward_events(
id: _,
msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_),
} => {}
+ Event {
+ id: _,
+ msg: EventMsg::TokenCount(_),
+ } => {}
Event {
id: _,
msg: EventMsg::SessionConfigured(_),
diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs
index 53864851ae..8eac13fd2e 100644
--- a/codex-rs/core/src/config/mod.rs
+++ b/codex-rs/core/src/config/mod.rs
@@ -2050,6 +2050,7 @@ trust_level = "trusted"
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
};
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
@@ -2170,6 +2171,7 @@ trust_level = "trusted"
managed_config_path: Some(managed_path),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
};
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs
index bc6d96bcb8..211a12fa03 100644
--- a/codex-rs/core/src/config/service.rs
+++ b/codex-rs/core/src/config/service.rs
@@ -755,6 +755,7 @@ remote_compaction = true
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
},
);
@@ -835,6 +836,7 @@ remote_compaction = true
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
},
);
@@ -937,6 +939,7 @@ remote_compaction = true
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
},
);
@@ -984,6 +987,7 @@ remote_compaction = true
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
},
);
@@ -1029,6 +1033,7 @@ remote_compaction = true
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
},
);
diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs
index d431272968..84a29a6119 100644
--- a/codex-rs/core/src/config_loader/layer_io.rs
+++ b/codex-rs/core/src/config_loader/layer_io.rs
@@ -33,11 +33,13 @@ pub(super) async fn load_config_layers_internal(
let LoaderOverrides {
managed_config_path,
managed_preferences_base64,
+ ..
} = overrides;
#[cfg(not(target_os = "macos"))]
let LoaderOverrides {
managed_config_path,
+ ..
} = overrides;
let managed_config_path = AbsolutePathBuf::from_absolute_path(
diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs
index 4a80267b90..8d2289e915 100644
--- a/codex-rs/core/src/config_loader/macos.rs
+++ b/codex-rs/core/src/config_loader/macos.rs
@@ -1,3 +1,4 @@
+use super::config_requirements::ConfigRequirementsToml;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use core_foundation::base::TCFType;
@@ -10,6 +11,7 @@ use toml::Value as TomlValue;
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
+const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
@@ -19,82 +21,126 @@ pub(crate) async fn load_managed_admin_config_layer(
return if trimmed.is_empty() {
Ok(None)
} else {
- parse_managed_preferences_base64(trimmed).map(Some)
+ parse_managed_config_base64(trimmed).map(Some)
};
}
- const LOAD_ERROR: &str = "Failed to load managed preferences configuration";
-
match task::spawn_blocking(load_managed_admin_config).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
- tracing::error!("Managed preferences load task was cancelled");
+ tracing::error!("Managed config load task was cancelled");
} else {
- tracing::error!("Managed preferences load task failed: {join_err}");
+ tracing::error!("Managed config load task failed: {join_err}");
}
- Err(io::Error::other(LOAD_ERROR))
+ Err(io::Error::other("Failed to load managed config"))
}
}
}
fn load_managed_admin_config() -> io::Result> {
+ load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
+ .as_deref()
+ .map(str::trim)
+ .map(parse_managed_config_base64)
+ .transpose()
+}
+
+pub(crate) async fn load_managed_admin_requirements_toml(
+ target: &mut ConfigRequirementsToml,
+ override_base64: Option<&str>,
+) -> io::Result<()> {
+ if let Some(encoded) = override_base64 {
+ let trimmed = encoded.trim();
+ if !trimmed.is_empty() {
+ target.merge_unset_fields(parse_managed_requirements_base64(trimmed)?);
+ }
+ return Ok(());
+ }
+
+ match task::spawn_blocking(load_managed_admin_requirements).await {
+ Ok(result) => {
+ if let Some(requirements) = result? {
+ target.merge_unset_fields(requirements);
+ }
+ Ok(())
+ }
+ Err(join_err) => {
+ if join_err.is_cancelled() {
+ tracing::error!("Managed requirements load task was cancelled");
+ } else {
+ tracing::error!("Managed requirements load task failed: {join_err}");
+ }
+ Err(io::Error::other("Failed to load managed requirements"))
+ }
+ }
+}
+
+fn load_managed_admin_requirements() -> io::Result > {
+ load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?
+ .as_deref()
+ .map(str::trim)
+ .map(parse_managed_requirements_base64)
+ .transpose()
+}
+
+fn load_managed_preference(key_name: &str) -> io::Result > {
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
}
- let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
- let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
-
let value_ref = unsafe {
CFPreferencesCopyAppValue(
- key.as_concrete_TypeRef(),
- application_id.as_concrete_TypeRef(),
+ CFString::new(key_name).as_concrete_TypeRef(),
+ CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(),
)
};
if value_ref.is_null() {
tracing::debug!(
- "Managed preferences for {} key {} not found",
- MANAGED_PREFERENCES_APPLICATION_ID,
- MANAGED_PREFERENCES_CONFIG_KEY
+ "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found",
);
return Ok(None);
}
- let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
- let contents = value.to_string();
- let trimmed = contents.trim();
-
- parse_managed_preferences_base64(trimmed).map(Some)
+ let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string();
+ Ok(Some(value))
}
-fn parse_managed_preferences_base64(encoded: &str) -> io::Result {
- let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
- tracing::error!("Failed to decode managed preferences as base64: {err}");
- io::Error::new(io::ErrorKind::InvalidData, err)
- })?;
-
- let decoded_str = String::from_utf8(decoded).map_err(|err| {
- tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
- io::Error::new(io::ErrorKind::InvalidData, err)
- })?;
-
- match toml::from_str::(&decoded_str) {
+fn parse_managed_config_base64(encoded: &str) -> io::Result {
+ match toml::from_str::(&decode_managed_preferences_base64(encoded)?) {
Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)),
Ok(other) => {
- tracing::error!(
- "Managed preferences TOML must have a table at the root, found {other:?}",
- );
+ tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
Err(io::Error::new(
io::ErrorKind::InvalidData,
- "managed preferences root must be a table",
+ "managed config root must be a table",
))
}
Err(err) => {
- tracing::error!("Failed to parse managed preferences TOML: {err}");
+ tracing::error!("Failed to parse managed config TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}
+
+fn parse_managed_requirements_base64(encoded: &str) -> io::Result {
+ toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err(
+ |err| {
+ tracing::error!("Failed to parse managed requirements TOML: {err}");
+ io::Error::new(io::ErrorKind::InvalidData, err)
+ },
+ )
+}
+
+fn decode_managed_preferences_base64(encoded: &str) -> io::Result {
+ String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
+ tracing::error!("Failed to decode managed value as base64: {err}",);
+ io::Error::new(io::ErrorKind::InvalidData, err)
+ })?)
+ .map_err(|err| {
+ tracing::error!("Managed value base64 contents were not valid UTF-8: {err}",);
+ io::Error::new(io::ErrorKind::InvalidData, err)
+ })
+}
diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs
index 73624c83c7..2dbba678ac 100644
--- a/codex-rs/core/src/config_loader/mod.rs
+++ b/codex-rs/core/src/config_loader/mod.rs
@@ -78,8 +78,14 @@ pub async fn load_config_layers_state(
) -> io::Result {
let mut config_requirements_toml = ConfigRequirementsToml::default();
- // TODO(gt): Support an entry in MDM for config requirements and use it
- // with `config_requirements_toml.merge_unset_fields(...)`, if present.
+ #[cfg(target_os = "macos")]
+ macos::load_managed_admin_requirements_toml(
+ &mut config_requirements_toml,
+ overrides
+ .macos_managed_config_requirements_base64
+ .as_deref(),
+ )
+ .await?;
// Honor /etc/codex/requirements.toml.
if cfg!(unix) {
@@ -101,8 +107,6 @@ pub async fn load_config_layers_state(
let mut layers = Vec::::new();
- // TODO(gt): Honor managed preferences (macOS only).
-
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = if cfg!(unix) {
diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs
index efb33dfac5..0ef14403d6 100644
--- a/codex-rs/core/src/config_loader/state.rs
+++ b/codex-rs/core/src/config_loader/state.rs
@@ -12,11 +12,14 @@ use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
+/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
pub managed_config_path: Option,
+ //TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
#[cfg(target_os = "macos")]
pub managed_preferences_base64: Option,
+ pub macos_managed_config_requirements_base64: Option,
}
#[derive(Debug, Clone, PartialEq)]
diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs
index bb8898129c..b80f00c71c 100644
--- a/codex-rs/core/src/config_loader/tests.rs
+++ b/codex-rs/core/src/config_loader/tests.rs
@@ -9,6 +9,8 @@ use crate::config_loader::config_requirements::ConfigRequirementsToml;
use crate::config_loader::fingerprint::version_for_toml;
use crate::config_loader::load_requirements_toml;
use codex_protocol::protocol::AskForApproval;
+#[cfg(target_os = "macos")]
+use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -43,6 +45,7 @@ extra = true
managed_config_path: Some(managed_path),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
};
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
@@ -73,10 +76,12 @@ extra = true
async fn returns_empty_when_all_layers_missing() {
let tmp = tempdir().expect("tempdir");
let managed_path = tmp.path().join("managed_config.toml");
+
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
+ macos_managed_config_requirements_base64: None,
};
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
@@ -141,12 +146,6 @@ async fn returns_empty_when_all_layers_missing() {
async fn managed_preferences_take_highest_precedence() {
use base64::Engine;
- let managed_payload = r#"
-[nested]
-value = "managed"
-flag = false
-"#;
- let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes());
let tmp = tempdir().expect("tempdir");
let managed_path = tmp.path().join("managed_config.toml");
@@ -168,7 +167,17 @@ flag = true
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path),
- managed_preferences_base64: Some(encoded),
+ managed_preferences_base64: Some(
+ base64::prelude::BASE64_STANDARD.encode(
+ r#"
+[nested]
+value = "managed"
+flag = false
+"#
+ .as_bytes(),
+ ),
+ ),
+ macos_managed_config_requirements_base64: None,
};
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
@@ -192,6 +201,108 @@ flag = true
assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false)));
}
+#[cfg(target_os = "macos")]
+#[tokio::test]
+async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
+ use base64::Engine;
+
+ let tmp = tempdir()?;
+
+ let state = load_config_layers_state(
+ tmp.path(),
+ Some(AbsolutePathBuf::try_from(tmp.path())?),
+ &[] as &[(String, TomlValue)],
+ LoaderOverrides {
+ managed_config_path: Some(tmp.path().join("managed_config.toml")),
+ managed_preferences_base64: Some(String::new()),
+ macos_managed_config_requirements_base64: Some(
+ base64::prelude::BASE64_STANDARD.encode(
+ r#"
+allowed_approval_policies = ["never"]
+allowed_sandbox_modes = ["read-only"]
+"#
+ .as_bytes(),
+ ),
+ ),
+ },
+ )
+ .await?;
+
+ assert_eq!(
+ state.requirements().approval_policy.value(),
+ AskForApproval::Never
+ );
+ assert_eq!(
+ *state.requirements().sandbox_policy.get(),
+ SandboxPolicy::ReadOnly
+ );
+ assert!(
+ state
+ .requirements()
+ .approval_policy
+ .can_set(&AskForApproval::OnRequest)
+ .is_err()
+ );
+ assert!(
+ state
+ .requirements()
+ .sandbox_policy
+ .can_set(&SandboxPolicy::WorkspaceWrite {
+ writable_roots: Vec::new(),
+ network_access: false,
+ exclude_tmpdir_env_var: false,
+ exclude_slash_tmp: false,
+ })
+ .is_err()
+ );
+
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+#[tokio::test]
+async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> {
+ use base64::Engine;
+
+ let tmp = tempdir()?;
+ let managed_path = tmp.path().join("managed_config.toml");
+
+ tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?;
+
+ let state = load_config_layers_state(
+ tmp.path(),
+ Some(AbsolutePathBuf::try_from(tmp.path())?),
+ &[] as &[(String, TomlValue)],
+ LoaderOverrides {
+ managed_config_path: Some(managed_path),
+ managed_preferences_base64: Some(String::new()),
+ macos_managed_config_requirements_base64: Some(
+ base64::prelude::BASE64_STANDARD.encode(
+ r#"
+allowed_approval_policies = ["never"]
+"#
+ .as_bytes(),
+ ),
+ ),
+ },
+ )
+ .await?;
+
+ assert_eq!(
+ state.requirements().approval_policy.value(),
+ AskForApproval::Never
+ );
+ assert!(
+ state
+ .requirements()
+ .approval_policy
+ .can_set(&AskForApproval::OnRequest)
+ .is_err()
+ );
+
+ Ok(())
+}
+
#[tokio::test(flavor = "current_thread")]
async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Result<()> {
let tmp = tempdir()?;
diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs
index 5caf7d9b82..234d89fef8 100644
--- a/codex-rs/core/src/exec_policy.rs
+++ b/codex-rs/core/src/exec_policy.rs
@@ -28,8 +28,8 @@ use crate::features::Feature;
use crate::features::Features;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
+use shlex::try_join as shlex_try_join;
-const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
const PROMPT_CONFLICT_REASON: &str =
"execpolicy requires approval for this command, but AskForApproval is set to Never";
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
@@ -128,7 +128,7 @@ impl ExecPolicyManager {
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
- reason: FORBIDDEN_REASON.to_string(),
+ reason: derive_forbidden_reason(command, &evaluation),
},
Decision::Prompt => {
if matches!(approval_policy, AskForApproval::Never) {
@@ -310,6 +310,53 @@ fn derive_prompt_reason(evaluation: &Evaluation) -> Option {
})
}
+fn render_shlex_command(args: &[String]) -> String {
+ shlex_try_join(args.iter().map(String::as_str)).unwrap_or_else(|_| args.join(" "))
+}
+
+fn derive_forbidden_reason(command_args: &[String], evaluation: &Evaluation) -> String {
+ let command = render_shlex_command(command_args);
+
+ let forbidden_reason = evaluation
+ .matched_rules
+ .iter()
+ .filter_map(|rule_match| match rule_match {
+ RuleMatch::PrefixRuleMatch {
+ matched_prefix,
+ decision: Decision::Forbidden,
+ forbidden_reason: Some(reason),
+ } => Some((matched_prefix.len(), reason.as_str())),
+ _ => None,
+ })
+ .max_by_key(|(matched_prefix_len, _)| *matched_prefix_len)
+ .map(|(_, reason)| reason);
+
+ if let Some(forbidden_reason) = forbidden_reason {
+ return format!("{command} rejected: {forbidden_reason}");
+ }
+
+ let matched_prefix = evaluation
+ .matched_rules
+ .iter()
+ .filter_map(|rule_match| match rule_match {
+ RuleMatch::PrefixRuleMatch {
+ matched_prefix,
+ decision: Decision::Forbidden,
+ ..
+ } => Some(matched_prefix),
+ _ => None,
+ })
+ .max_by_key(|matched_prefix| matched_prefix.len());
+
+ match matched_prefix {
+ Some(prefix) => {
+ let prefix = render_shlex_command(prefix);
+ format!("{command} rejected: policy forbids commands starting with `{prefix}`")
+ }
+ None => format!("{command} rejected: blocked by policy"),
+ }
+}
+
async fn collect_policy_files(dir: impl AsRef) -> Result, ExecPolicyError> {
let dir = dir.as_ref();
let mut read_dir = match fs::read_dir(dir).await {
@@ -450,7 +497,8 @@ mod tests {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
- decision: Decision::Forbidden
+ decision: Decision::Forbidden,
+ forbidden_reason: None,
}],
},
policy.check_multiple(command.iter(), &|_| Decision::Allow)
@@ -528,7 +576,8 @@ mod tests {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
- decision: Decision::Forbidden
+ decision: Decision::Forbidden,
+ forbidden_reason: None,
}],
},
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
@@ -538,7 +587,8 @@ mod tests {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["ls".to_string()],
- decision: Decision::Prompt
+ decision: Decision::Prompt,
+ forbidden_reason: None,
}],
},
policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow)
@@ -560,7 +610,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
let forbidden_script = vec![
"bash".to_string(),
"-lc".to_string(),
- "rm -rf /tmp".to_string(),
+ "rm -rf /some/important/folder".to_string(),
];
let manager = ExecPolicyManager::new(policy);
@@ -574,10 +624,52 @@ prefix_rule(pattern=["rm"], decision="forbidden")
)
.await;
+ let forbidden_script_rendered =
+ shlex::try_join(forbidden_script.iter().map(String::as_str)).expect("shlex join");
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
- reason: FORBIDDEN_REASON.to_string()
+ reason: format!(
+ "{forbidden_script_rendered} rejected: policy forbids commands starting with `rm`"
+ )
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn forbidden_reason_is_included_in_forbidden_exec_approval_requirement() {
+ let policy_src = r#"
+prefix_rule(
+ pattern=["rm"],
+ decision="forbidden",
+ forbidden_reason="destructive command",
+)
+"#;
+ let mut parser = PolicyParser::new();
+ parser
+ .parse("test.rules", policy_src)
+ .expect("parse policy");
+ let policy = Arc::new(parser.build());
+
+ let manager = ExecPolicyManager::new(policy);
+ let requirement = manager
+ .create_exec_approval_requirement_for_command(
+ &Features::with_defaults(),
+ &[
+ "rm".to_string(),
+ "-rf".to_string(),
+ "/some/important/folder".to_string(),
+ ],
+ AskForApproval::OnRequest,
+ &SandboxPolicy::DangerFullAccess,
+ SandboxPermissions::UseDefault,
+ )
+ .await;
+
+ assert_eq!(
+ requirement,
+ ExecApprovalRequirement::Forbidden {
+ reason: "rm -rf /some/important/folder rejected: destructive command".to_string()
}
);
}
diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs
index 242dd38c89..3b22bfc3f4 100644
--- a/codex-rs/core/src/features.rs
+++ b/codex-rs/core/src/features.rs
@@ -255,6 +255,10 @@ impl Features {
features
}
+
+ pub fn enabled_features(&self) -> Vec {
+ self.enabled.iter().copied().collect()
+ }
}
/// Keys accepted in `[features]` tables.
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
index 6c0b48b1bd..dcd1edf80c 100644
--- a/codex-rs/core/src/mcp_connection_manager.rs
+++ b/codex-rs/core/src/mcp_connection_manager.rs
@@ -79,26 +79,60 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for individual tool calls.
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
+/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
+/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
+/// name we expose to the model by replacing any disallowed character with `_`.
+fn sanitize_responses_api_tool_name(name: &str) -> String {
+ let mut sanitized = String::with_capacity(name.len());
+ for c in name.chars() {
+ if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
+ sanitized.push(c);
+ } else {
+ sanitized.push('_');
+ }
+ }
+
+ if sanitized.is_empty() {
+ "_".to_string()
+ } else {
+ sanitized
+ }
+}
+
+fn sha1_hex(s: &str) -> String {
+ let mut hasher = Sha1::new();
+ hasher.update(s.as_bytes());
+ let sha1 = hasher.finalize();
+ format!("{sha1:x}")
+}
+
fn qualify_tools(tools: I) -> HashMap
where
I: IntoIterator- ,
{
let mut used_names = HashSet::new();
+ let mut seen_raw_names = HashSet::new();
let mut qualified_tools = HashMap::new();
for tool in tools {
- let mut qualified_name = format!(
+ let qualified_name_raw = format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
);
+ if !seen_raw_names.insert(qualified_name_raw.clone()) {
+ warn!("skipping duplicated tool {}", qualified_name_raw);
+ continue;
+ }
+
+ // Start from a "pretty" name (sanitized), then deterministically disambiguate on
+ // collisions by appending a hash of the *raw* (unsanitized) qualified name. This
+ // ensures tools like `foo.bar` and `foo_bar` don't collapse to the same key.
+ let mut qualified_name = sanitize_responses_api_tool_name(&qualified_name_raw);
+
+ // Enforce length constraints early; use the raw name for the hash input so the
+ // output remains stable even when sanitization changes.
if qualified_name.len() > MAX_TOOL_NAME_LENGTH {
- let mut hasher = Sha1::new();
- hasher.update(qualified_name.as_bytes());
- let sha1 = hasher.finalize();
- let sha1_str = format!("{sha1:x}");
-
- // Truncate to make room for the hash suffix
+ let sha1_str = sha1_hex(&qualified_name_raw);
let prefix_len = MAX_TOOL_NAME_LENGTH - sha1_str.len();
-
qualified_name = format!("{}{}", &qualified_name[..prefix_len], sha1_str);
}
@@ -1035,6 +1069,28 @@ mod tests {
);
}
+ #[test]
+ fn test_qualify_tools_sanitizes_invalid_characters() {
+ let tools = vec![create_test_tool("server.one", "tool.two")];
+
+ let qualified_tools = qualify_tools(tools);
+
+ assert_eq!(qualified_tools.len(), 1);
+ let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
+ assert_eq!(qualified_name, "mcp__server_one__tool_two");
+
+ // The key is sanitized for OpenAI, but we keep original parts for the actual MCP call.
+ assert_eq!(tool.server_name, "server.one");
+ assert_eq!(tool.tool_name, "tool.two");
+
+ assert!(
+ qualified_name
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
+ "qualified name must be Responses API compatible: {qualified_name:?}"
+ );
+ }
+
#[test]
fn tool_filter_allows_by_default() {
let filter = ToolFilter::default();
diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs
index 5c53f5fc16..060f4a5c27 100644
--- a/codex-rs/core/src/models_manager/manager.rs
+++ b/codex-rs/core/src/models_manager/manager.rs
@@ -77,7 +77,7 @@ impl ModelsManager {
}
/// Fetch the latest remote models, using the on-disk cache when still fresh.
- pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> {
+ pub async fn refresh_available_models_with_cache(&self, config: &Config) -> CoreResult<()> {
if !config.features.enabled(Feature::RemoteModels)
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
{
@@ -86,7 +86,17 @@ impl ModelsManager {
if self.try_load_cache().await {
return Ok(());
}
+ self.refresh_available_models_no_cache(config.features.enabled(Feature::RemoteModels))
+ .await
+ }
+ pub(crate) async fn refresh_available_models_no_cache(
+ &self,
+ remote_models_feature: bool,
+ ) -> CoreResult<()> {
+ if !remote_models_feature || self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
+ return Ok(());
+ }
let auth = self.auth_manager.auth();
let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?;
@@ -94,13 +104,11 @@ impl ModelsManager {
let client = ModelsClient::new(transport, api_provider, api_auth);
let client_version = format_client_version_to_whole();
- let ModelsResponse { models, etag } = client
+ let (models, etag) = client
.list_models(&client_version, HeaderMap::new())
.await
.map_err(map_api_error)?;
- let etag = (!etag.is_empty()).then_some(etag);
-
self.apply_remote_models(models.clone()).await;
*self.etag.write().await = etag.clone();
self.persist_cache(&models, etag).await;
@@ -108,7 +116,7 @@ impl ModelsManager {
}
pub async fn list_models(&self, config: &Config) -> Vec
{
- if let Err(err) = self.refresh_available_models(config).await {
+ if let Err(err) = self.refresh_available_models_with_cache(config).await {
error!("failed to refresh available models: {err}");
}
let remote_models = self.remote_models(config).await;
@@ -135,7 +143,7 @@ impl ModelsManager {
if let Some(model) = model.as_ref() {
return model.to_string();
}
- if let Err(err) = self.refresh_available_models(config).await {
+ if let Err(err) = self.refresh_available_models_with_cache(config).await {
error!("failed to refresh available models: {err}");
}
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
@@ -153,6 +161,18 @@ impl ModelsManager {
}
OPENAI_DEFAULT_API_MODEL.to_string()
}
+ pub async fn refresh_if_new_etag(&self, etag: String, remote_models_feature: bool) {
+ let current_etag = self.get_etag().await;
+ if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
+ return;
+ }
+ if let Err(err) = self
+ .refresh_available_models_no_cache(remote_models_feature)
+ .await
+ {
+ error!("failed to refresh available models: {err}");
+ }
+ }
#[cfg(any(test, feature = "test-support"))]
pub fn get_model_offline(model: Option<&str>) -> String {
@@ -165,6 +185,10 @@ impl ModelsManager {
Self::find_family_for_model(model).with_config_overrides(config)
}
+ async fn get_etag(&self) -> Option {
+ self.etag.read().await.clone()
+ }
+
/// Replace the cached remote models and rebuild the derived presets list.
async fn apply_remote_models(&self, models: Vec) {
*self.remote_models.write().await = models;
@@ -288,26 +312,14 @@ impl ModelsManager {
/// Convert a client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3")
fn format_client_version_to_whole() -> String {
- format_client_version_from_parts(
+ format!(
+ "{}.{}.{}",
env!("CARGO_PKG_VERSION_MAJOR"),
env!("CARGO_PKG_VERSION_MINOR"),
- env!("CARGO_PKG_VERSION_PATCH"),
+ env!("CARGO_PKG_VERSION_PATCH")
)
}
-fn format_client_version_from_parts(major: &str, minor: &str, patch: &str) -> String {
- const DEV_VERSION: &str = "0.0.0";
- const FALLBACK_VERSION: &str = "99.99.99";
-
- let normalized = format!("{major}.{minor}.{patch}");
-
- if normalized == DEV_VERSION {
- FALLBACK_VERSION.to_string()
- } else {
- normalized
- }
-}
-
#[cfg(test)]
mod tests {
use super::cache::ModelsCache;
@@ -388,7 +400,6 @@ mod tests {
&server,
ModelsResponse {
models: remote_models.clone(),
- etag: String::new(),
},
)
.await;
@@ -406,7 +417,7 @@ mod tests {
let manager = ModelsManager::with_provider(auth_manager, provider);
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("refresh succeeds");
let cached_remote = manager.remote_models(&config).await;
@@ -445,7 +456,6 @@ mod tests {
&server,
ModelsResponse {
models: remote_models.clone(),
- etag: String::new(),
},
)
.await;
@@ -466,7 +476,7 @@ mod tests {
let manager = ModelsManager::with_provider(auth_manager, provider);
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("first refresh succeeds");
assert_eq!(
@@ -477,7 +487,7 @@ mod tests {
// Second call should read from cache and avoid the network.
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("cached refresh succeeds");
assert_eq!(
@@ -500,7 +510,6 @@ mod tests {
&server,
ModelsResponse {
models: initial_models.clone(),
- etag: String::new(),
},
)
.await;
@@ -521,7 +530,7 @@ mod tests {
let manager = ModelsManager::with_provider(auth_manager, provider);
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("initial refresh succeeds");
@@ -541,13 +550,12 @@ mod tests {
&server,
ModelsResponse {
models: updated_models.clone(),
- etag: String::new(),
},
)
.await;
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("second refresh succeeds");
assert_eq!(
@@ -575,7 +583,6 @@ mod tests {
&server,
ModelsResponse {
models: initial_models,
- etag: String::new(),
},
)
.await;
@@ -594,7 +601,7 @@ mod tests {
manager.cache_ttl = Duration::ZERO;
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("initial refresh succeeds");
@@ -604,13 +611,12 @@ mod tests {
&server,
ModelsResponse {
models: refreshed_models,
- etag: String::new(),
},
)
.await;
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("second refresh succeeds");
diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs
index db2d0e74eb..a100f28443 100644
--- a/codex-rs/core/src/util.rs
+++ b/codex-rs/core/src/util.rs
@@ -9,6 +9,31 @@ use tracing::error;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
+/// Emit structured feedback metadata as key/value pairs.
+///
+/// This logs a tracing event with `target: "feedback_tags"`. If
+/// `codex_feedback::CodexFeedback::metadata_layer()` is installed, these fields are captured and
+/// later attached as tags when feedback is uploaded.
+///
+/// Values are wrapped with [`tracing::field::DebugValue`], so the expression only needs to
+/// implement [`std::fmt::Debug`].
+///
+/// Example:
+///
+/// ```rust
+/// codex_core::feedback_tags!(model = "gpt-5", cached = true);
+/// codex_core::feedback_tags!(provider = provider_id, request_id = request_id);
+/// ```
+#[macro_export]
+macro_rules! feedback_tags {
+ ($( $key:ident = $value:expr ),+ $(,)?) => {
+ ::tracing::info!(
+ target: "feedback_tags",
+ $( $key = ::tracing::field::debug(&$value) ),+
+ );
+ };
+}
+
pub(crate) fn backoff(attempt: u64) -> Duration {
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
@@ -74,4 +99,12 @@ mod tests {
let message = try_parse_error_message(text);
assert_eq!(message, r#"{"message": "test"}"#);
}
+
+ #[test]
+ fn feedback_tags_macro_compiles() {
+ #[derive(Debug)]
+ struct OnlyDebug;
+
+ feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug);
+ }
}
diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs
index b98b29625e..3934771409 100644
--- a/codex-rs/core/tests/common/responses.rs
+++ b/codex-rs/core/tests/common/responses.rs
@@ -670,6 +670,25 @@ pub async fn mount_models_once(server: &MockServer, body: ModelsResponse) -> Mod
models_mock
}
+pub async fn mount_models_once_with_etag(
+ server: &MockServer,
+ body: ModelsResponse,
+ etag: &str,
+) -> ModelsMock {
+ let (mock, models_mock) = models_mock();
+ mock.respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ // ModelsClient reads the ETag header, not a JSON field.
+ .insert_header("ETag", etag)
+ .set_body_json(body.clone()),
+ )
+ .up_to_n_times(1)
+ .mount(server)
+ .await;
+ models_mock
+}
+
pub async fn start_mock_server() -> MockServer {
let server = MockServer::builder()
.body_print_limit(BodyPrintLimit::Limited(80_000))
@@ -677,14 +696,7 @@ pub async fn start_mock_server() -> MockServer {
.await;
// Provide a default `/models` response so tests remain hermetic when the client queries it.
- let _ = mount_models_once(
- &server,
- ModelsResponse {
- models: Vec::new(),
- etag: String::new(),
- },
- )
- .await;
+ let _ = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await;
server
}
diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs
index 4f57330a28..c7556e3388 100644
--- a/codex-rs/core/tests/suite/compact.rs
+++ b/codex-rs/core/tests/suite/compact.rs
@@ -8,11 +8,14 @@ use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::compact::SUMMARY_PREFIX;
use codex_core::config::Config;
use codex_core::features::Feature;
+use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::RolloutItem;
use codex_core::protocol::RolloutLine;
+use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WarningEvent;
+use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::ev_local_shell_call;
@@ -1228,6 +1231,117 @@ async fn auto_compact_runs_after_token_limit_hit() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
+ skip_if_no_network!();
+
+ let server = start_mock_server().await;
+
+ let limit = 200_000;
+ let over_limit_tokens = 250_000;
+ let remote_summary = "REMOTE_COMPACT_SUMMARY";
+
+ let compacted_history = vec![
+ codex_protocol::models::ResponseItem::Message {
+ id: None,
+ role: "assistant".to_string(),
+ content: vec![codex_protocol::models::ContentItem::OutputText {
+ text: remote_summary.to_string(),
+ }],
+ },
+ codex_protocol::models::ResponseItem::Compaction {
+ encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
+ },
+ ];
+ let compact_mock =
+ mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await;
+
+ let mut builder = test_codex().with_config(move |config| {
+ set_test_compact_prompt(config);
+ config.model_auto_compact_token_limit = Some(limit);
+ config.features.enable(Feature::RemoteCompaction);
+ });
+ let initial = builder.build(&server).await.unwrap();
+ let home = initial.home.clone();
+ let rollout_path = initial.session_configured.rollout_path.clone();
+
+ // A single over-limit completion should not auto-compact until the next user message.
+ mount_sse_once(
+ &server,
+ sse(vec![
+ ev_assistant_message("m1", FIRST_REPLY),
+ ev_completed_with_tokens("r1", over_limit_tokens),
+ ]),
+ )
+ .await;
+ initial.submit_turn("OVER_LIMIT_TURN").await.unwrap();
+
+ assert!(
+ compact_mock.requests().is_empty(),
+ "remote compaction should not run before the next user message"
+ );
+
+ let mut resume_builder = test_codex().with_config(move |config| {
+ set_test_compact_prompt(config);
+ config.model_auto_compact_token_limit = Some(limit);
+ config.features.enable(Feature::RemoteCompaction);
+ });
+ let resumed = resume_builder
+ .resume(&server, home, rollout_path)
+ .await
+ .unwrap();
+
+ let follow_up_user = "AFTER_RESUME_USER";
+ let sse_follow_up = sse(vec![
+ ev_assistant_message("m2", FINAL_REPLY),
+ ev_completed("r2"),
+ ]);
+
+ let follow_up_matcher = move |req: &wiremock::Request| {
+ let body = std::str::from_utf8(&req.body).unwrap_or("");
+ body.contains(follow_up_user) && body.contains(remote_summary)
+ };
+ mount_sse_once_match(&server, follow_up_matcher, sse_follow_up).await;
+
+ resumed
+ .codex
+ .submit(Op::UserTurn {
+ items: vec![UserInput::Text {
+ text: follow_up_user.into(),
+ }],
+ final_output_json_schema: None,
+ cwd: resumed.cwd.path().to_path_buf(),
+ approval_policy: AskForApproval::Never,
+ sandbox_policy: SandboxPolicy::DangerFullAccess,
+ model: resumed.session_configured.model.clone(),
+ effort: None,
+ summary: ReasoningSummary::Auto,
+ })
+ .await
+ .unwrap();
+
+ wait_for_event(&resumed.codex, |event| {
+ matches!(event, EventMsg::ContextCompacted(_))
+ })
+ .await;
+ wait_for_event(&resumed.codex, |event| {
+ matches!(event, EventMsg::TaskComplete(_))
+ })
+ .await;
+
+ let compact_requests = compact_mock.requests();
+ assert_eq!(
+ compact_requests.len(),
+ 1,
+ "remote compaction should run once after resume"
+ );
+ assert_eq!(
+ compact_requests[0].path(),
+ "/v1/responses/compact",
+ "remote compaction should hit the compact endpoint"
+ );
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compact_persists_rollout_entries() {
skip_if_no_network!();
diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs
index 470478ad75..bde1e9ca4e 100644
--- a/codex-rs/core/tests/suite/exec_policy.rs
+++ b/codex-rs/core/tests/suite/exec_policy.rs
@@ -97,7 +97,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
assert!(
end.aggregated_output
- .contains("execpolicy forbids this command"),
+ .contains("rejected: policy forbids commands starting with `echo`"),
"unexpected output: {}",
end.aggregated_output
);
diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs
index 242d1c3219..63784bd403 100644
--- a/codex-rs/core/tests/suite/mod.rs
+++ b/codex-rs/core/tests/suite/mod.rs
@@ -37,6 +37,7 @@ mod list_models;
mod live_cli;
mod model_overrides;
mod model_tools;
+mod models_etag_responses;
mod otel;
mod prompt_caching;
mod quota_exceeded;
diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs
new file mode 100644
index 0000000000..24f0655cec
--- /dev/null
+++ b/codex-rs/core/tests/suite/models_etag_responses.rs
@@ -0,0 +1,139 @@
+#![cfg(not(target_os = "windows"))]
+
+use std::sync::Arc;
+
+use anyhow::Result;
+use codex_core::CodexAuth;
+use codex_core::features::Feature;
+use codex_core::protocol::AskForApproval;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::Op;
+use codex_core::protocol::SandboxPolicy;
+use codex_protocol::config_types::ReasoningSummary;
+use codex_protocol::openai_models::ModelsResponse;
+use codex_protocol::user_input::UserInput;
+use core_test_support::responses;
+use core_test_support::responses::ev_assistant_message;
+use core_test_support::responses::ev_completed;
+use core_test_support::responses::ev_local_shell_call;
+use core_test_support::responses::ev_response_created;
+use core_test_support::responses::sse;
+use core_test_support::responses::sse_response;
+use core_test_support::skip_if_no_network;
+use core_test_support::test_codex::test_codex;
+use core_test_support::wait_for_event;
+use pretty_assertions::assert_eq;
+use wiremock::MockServer;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch() -> Result<()> {
+ skip_if_no_network!(Ok(()));
+
+ const ETAG_1: &str = "\"models-etag-1\"";
+ const ETAG_2: &str = "\"models-etag-2\"";
+ const CALL_ID: &str = "local-shell-call-1";
+
+ let server = MockServer::start().await;
+
+ // 1) On spawn, Codex fetches /models and stores the ETag.
+ let spawn_models_mock = responses::mount_models_once_with_etag(
+ &server,
+ ModelsResponse { models: Vec::new() },
+ ETAG_1,
+ )
+ .await;
+
+ let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
+ let mut builder = test_codex()
+ .with_auth(auth)
+ .with_model("gpt-5")
+ .with_config(|config| {
+ config.features.enable(Feature::RemoteModels);
+ // Keep this test deterministic: no request retries, and a small stream retry budget.
+ config.model_provider.request_max_retries = Some(0);
+ config.model_provider.stream_max_retries = Some(1);
+ });
+
+ let test = builder.build(&server).await?;
+ let codex = Arc::clone(&test.codex);
+ let cwd = Arc::clone(&test.cwd);
+ let session_model = test.session_configured.model.clone();
+
+ assert_eq!(spawn_models_mock.requests().len(), 1);
+ assert_eq!(spawn_models_mock.single_request_path(), "/v1/models");
+
+ // 2) If the server sends a different X-Models-Etag on /responses, Codex refreshes /models.
+ let refresh_models_mock = responses::mount_models_once_with_etag(
+ &server,
+ ModelsResponse { models: Vec::new() },
+ ETAG_2,
+ )
+ .await;
+
+ // First /responses request (user message) succeeds and returns a tool call.
+ // It also includes a mismatched X-Models-Etag, which should trigger a /models refresh.
+ let first_response_body = sse(vec![
+ ev_response_created("resp-1"),
+ ev_local_shell_call(CALL_ID, "completed", vec!["/bin/echo", "etag ok"]),
+ ev_completed("resp-1"),
+ ]);
+ responses::mount_response_once(
+ &server,
+ sse_response(first_response_body).insert_header("X-Models-Etag", ETAG_2),
+ )
+ .await;
+
+ // Second /responses request (tool output) includes the same X-Models-Etag; Codex should not
+ // refetch /models again after it has already refreshed the catalog.
+ let completion_response_body = sse(vec![
+ ev_response_created("resp-2"),
+ ev_assistant_message("msg-1", "done"),
+ ev_completed("resp-2"),
+ ]);
+ let tool_output_mock = responses::mount_response_once(
+ &server,
+ sse_response(completion_response_body).insert_header("X-Models-Etag", ETAG_2),
+ )
+ .await;
+
+ codex
+ .submit(Op::UserTurn {
+ items: vec![UserInput::Text {
+ text: "please run a tool".into(),
+ }],
+ final_output_json_schema: None,
+ cwd: cwd.path().to_path_buf(),
+ approval_policy: AskForApproval::Never,
+ sandbox_policy: SandboxPolicy::DangerFullAccess,
+ model: session_model,
+ effort: None,
+ summary: ReasoningSummary::Auto,
+ })
+ .await?;
+
+ let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ // Assert /models was refreshed exactly once after the X-Models-Etag mismatch.
+ assert_eq!(refresh_models_mock.requests().len(), 1);
+ assert_eq!(refresh_models_mock.single_request_path(), "/v1/models");
+ let refresh_req = refresh_models_mock
+ .requests()
+ .into_iter()
+ .next()
+ .expect("one request");
+ // Ensure Codex includes client_version on refresh. (This is a stable signal that we're using the /models client.)
+ assert!(
+ refresh_req
+ .url
+ .query_pairs()
+ .any(|(k, _)| k == "client_version"),
+ "expected /models refresh to include client_version query param"
+ );
+
+ // Assert the tool output /responses request succeeded and did not trigger another /models fetch.
+ let tool_req = tool_output_mock.single_request();
+ let _ = tool_req.function_call_output(CALL_ID);
+ assert_eq!(refresh_models_mock.requests().len(), 1);
+
+ Ok(())
+}
diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs
index 7f2719776f..201d61c9e0 100644
--- a/codex-rs/core/tests/suite/remote_models.rs
+++ b/codex-rs/core/tests/suite/remote_models.rs
@@ -89,7 +89,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
&server,
ModelsResponse {
models: vec![remote_model],
- etag: String::new(),
},
)
.await;
@@ -226,7 +225,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
&server,
ModelsResponse {
models: vec![remote_model],
- etag: String::new(),
},
)
.await;
@@ -304,7 +302,6 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> {
&server,
ModelsResponse {
models: vec![remote_model.clone()],
- etag: String::new(),
},
)
.await;
@@ -324,7 +321,7 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> {
);
manager
- .refresh_available_models(&config)
+ .refresh_available_models_with_cache(&config)
.await
.expect("refresh succeeds");
@@ -362,7 +359,6 @@ async fn remote_models_hide_picker_only_models() -> Result<()> {
&server,
ModelsResponse {
models: vec![remote_model],
- etag: String::new(),
},
)
.await;
diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs
index fba7af588c..b88abe7ac7 100644
--- a/codex-rs/core/tests/suite/review.rs
+++ b/codex-rs/core/tests/suite/review.rs
@@ -709,6 +709,105 @@ async fn review_history_surfaces_in_parent_session() {
server.verify().await;
}
+/// `/review` should use the session's current cwd (including runtime overrides)
+/// when resolving base-branch review prompts (merge-base computation).
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
+ skip_if_no_network!();
+
+ let sse_raw = r#"[{"type":"response.completed", "response": {"id": "__ID__"}}]"#;
+ let server = start_responses_server_with_sse(sse_raw, 1).await;
+
+ let initial_cwd = TempDir::new().unwrap();
+
+ let repo_dir = TempDir::new().unwrap();
+ let repo_path = repo_dir.path();
+
+ fn run_git(repo_path: &std::path::Path, args: &[&str]) {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(args)
+ .output()
+ .expect("spawn git");
+ assert!(
+ output.status.success(),
+ "git {:?} failed: stdout={:?} stderr={:?}",
+ args,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ run_git(repo_path, &["init", "-b", "main"]);
+ run_git(repo_path, &["config", "user.email", "test@example.com"]);
+ run_git(repo_path, &["config", "user.name", "Test User"]);
+ std::fs::write(repo_path.join("file.txt"), "hello\n").unwrap();
+ run_git(repo_path, &["add", "."]);
+ run_git(repo_path, &["commit", "-m", "initial"]);
+
+ let head_sha = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .expect("rev-parse HEAD");
+ assert!(head_sha.status.success());
+ let head_sha = String::from_utf8(head_sha.stdout)
+ .expect("utf8 sha")
+ .trim()
+ .to_string();
+
+ let codex_home = TempDir::new().unwrap();
+ let codex = new_conversation_for_server(&server, &codex_home, |config| {
+ config.cwd = initial_cwd.path().to_path_buf();
+ })
+ .await;
+
+ codex
+ .submit(Op::OverrideTurnContext {
+ cwd: Some(repo_path.to_path_buf()),
+ approval_policy: None,
+ sandbox_policy: None,
+ model: None,
+ effort: None,
+ summary: None,
+ })
+ .await
+ .unwrap();
+
+ codex
+ .submit(Op::Review {
+ review_request: ReviewRequest {
+ target: ReviewTarget::BaseBranch {
+ branch: "main".to_string(),
+ },
+ user_facing_hint: None,
+ },
+ })
+ .await
+ .unwrap();
+
+ let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await;
+ let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ let requests = get_responses_requests(&server).await;
+ assert_eq!(requests.len(), 1);
+ let body = requests[0].body_json::().unwrap();
+ let input = body["input"].as_array().expect("input array");
+
+ let saw_merge_base_sha = input
+ .iter()
+ .filter_map(|msg| msg["content"][0]["text"].as_str())
+ .any(|text| text.contains(&head_sha));
+ assert!(
+ saw_merge_base_sha,
+ "expected review prompt to include merge-base sha {head_sha}"
+ );
+
+ server.verify().await;
+}
+
/// Start a mock Responses API server and mount the given SSE stream body.
async fn start_responses_server_with_sse(sse_raw: &str, expected_requests: usize) -> MockServer {
let server = MockServer::start().await;
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index a43718d569..40afab7c9c 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -222,7 +222,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_msg!(self, "{}", message.style(self.dimmed));
}
- EventMsg::StreamError(StreamErrorEvent { message, .. }) => {
+ EventMsg::StreamError(StreamErrorEvent {
+ message,
+ additional_details,
+ ..
+ }) => {
+ let message = match additional_details {
+ Some(details) if !details.trim().is_empty() => format!("{message} ({details})"),
+ _ => message,
+ };
ts_msg!(self, "{}", message.style(self.dimmed));
}
EventMsg::TaskStarted(_) => {
diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs
index 03c51662b1..0b2df54455 100644
--- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs
+++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs
@@ -145,9 +145,15 @@ impl EventProcessorWithJsonOutput {
};
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
}
- EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent {
- message: ev.message.clone(),
- })],
+ EventMsg::StreamError(ev) => {
+ let message = match &ev.additional_details {
+ Some(details) if !details.trim().is_empty() => {
+ format!("{} ({})", ev.message, details)
+ }
+ _ => ev.message.clone(),
+ };
+ vec![ThreadEvent::Error(ThreadErrorEvent { message })]
+ }
EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev),
_ => Vec::new(),
}
diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs
index 2b3673f5a6..d288f568e8 100644
--- a/codex-rs/exec/tests/event_processor_with_json_output.rs
+++ b/codex-rs/exec/tests/event_processor_with_json_output.rs
@@ -583,6 +583,7 @@ fn stream_error_event_produces_error() {
EventMsg::StreamError(codex_core::protocol::StreamErrorEvent {
message: "retrying".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
+ additional_details: None,
}),
));
assert_eq!(
diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md
index 288a46dcbc..3e527e08b8 100644
--- a/codex-rs/execpolicy/README.md
+++ b/codex-rs/execpolicy/README.md
@@ -1,9 +1,10 @@
# codex-execpolicy
## Overview
-- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, match?, not_match?)`.
+- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, forbidden_reason?, match?, not_match?)`.
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
+- When `decision = "forbidden"`, an optional `forbidden_reason` can be provided to explain why the command is blocked.
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
- The CLI always prints the JSON serialization of the evaluation result.
- The legacy rule matcher lives in `codex-execpolicy-legacy`.
@@ -14,6 +15,7 @@
prefix_rule(
pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives
decision = "prompt", # allow | prompt | forbidden; defaults to allow
+ forbidden_reason = "explain why this is forbidden", # only valid when decision="forbidden"
match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule
not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule
)
@@ -40,7 +42,8 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
{
"prefixRuleMatch": {
"matchedPrefix": ["", "..."],
- "decision": "allow|prompt|forbidden"
+ "decision": "allow|prompt|forbidden",
+ "forbiddenReason": "..."
}
}
],
diff --git a/codex-rs/execpolicy/examples/example.codexpolicy b/codex-rs/execpolicy/examples/example.codexpolicy
index 5bb691b6f7..1469beacae 100644
--- a/codex-rs/execpolicy/examples/example.codexpolicy
+++ b/codex-rs/execpolicy/examples/example.codexpolicy
@@ -4,6 +4,7 @@
prefix_rule(
pattern = ["git", "reset", "--hard"],
decision = "forbidden",
+ forbidden_reason = "destructive operation",
match = [
["git", "reset", "--hard"],
],
diff --git a/codex-rs/execpolicy/src/error.rs b/codex-rs/execpolicy/src/error.rs
index 2f168a027e..9664e71a5c 100644
--- a/codex-rs/execpolicy/src/error.rs
+++ b/codex-rs/execpolicy/src/error.rs
@@ -11,6 +11,8 @@ pub enum Error {
InvalidPattern(String),
#[error("invalid example: {0}")]
InvalidExample(String),
+ #[error("invalid rule: {0}")]
+ InvalidRule(String),
#[error(
"expected every example to match at least one rule. rules: {rules:?}; unmatched examples: \
{examples:?}"
diff --git a/codex-rs/execpolicy/src/parser.rs b/codex-rs/execpolicy/src/parser.rs
index d505490554..92b3a16a99 100644
--- a/codex-rs/execpolicy/src/parser.rs
+++ b/codex-rs/execpolicy/src/parser.rs
@@ -212,6 +212,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
decision: Option<&'v str>,
r#match: Option>>,
not_match: Option>>,
+ forbidden_reason: Option<&'v str>,
eval: &mut Evaluator<'v, '_, '_>,
) -> anyhow::Result {
let decision = match decision {
@@ -219,6 +220,23 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
None => Decision::Allow,
};
+ let forbidden_reason = match forbidden_reason {
+ Some(raw) if raw.trim().is_empty() => {
+ return Err(
+ Error::InvalidRule("forbidden_reason cannot be empty".to_string()).into(),
+ );
+ }
+ Some(raw) => Some(raw.to_string()),
+ None => None,
+ };
+
+ if forbidden_reason.is_some() && decision != Decision::Forbidden {
+ return Err(Error::InvalidRule(format!(
+ "forbidden_reason requires decision=\"forbidden\" (got {decision:?})"
+ ))
+ .into());
+ }
+
let pattern_tokens = parse_pattern(pattern)?;
let matches: Vec> =
@@ -246,6 +264,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
rest: rest.clone(),
},
decision,
+ forbidden_reason: forbidden_reason.clone(),
}) as RuleRef
})
.collect();
diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs
index 991e904ae9..fa438f0815 100644
--- a/codex-rs/execpolicy/src/policy.rs
+++ b/codex-rs/execpolicy/src/policy.rs
@@ -46,6 +46,7 @@ impl Policy {
.into(),
},
decision,
+ forbidden_reason: None,
});
self.rules_by_program.insert(first_token.clone(), rule);
diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs
index cd0756bbb3..59edb23cbd 100644
--- a/codex-rs/execpolicy/src/rule.rs
+++ b/codex-rs/execpolicy/src/rule.rs
@@ -63,6 +63,11 @@ pub enum RuleMatch {
#[serde(rename = "matchedPrefix")]
matched_prefix: Vec,
decision: Decision,
+ /// Optional explanation for why a matching rule forbids this command.
+ ///
+ /// Only present when provided in the policy and `decision == forbidden`.
+ #[serde(rename = "forbiddenReason", skip_serializing_if = "Option::is_none")]
+ forbidden_reason: Option,
},
HeuristicsRuleMatch {
command: Vec,
@@ -83,6 +88,7 @@ impl RuleMatch {
pub struct PrefixRule {
pub pattern: PrefixPattern,
pub decision: Decision,
+ pub forbidden_reason: Option,
}
pub trait Rule: Any + Debug + Send + Sync {
@@ -104,6 +110,7 @@ impl Rule for PrefixRule {
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: self.decision,
+ forbidden_reason: self.forbidden_reason.clone(),
})
}
}
diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs
index 7ae5e6e213..6f732751c9 100644
--- a/codex-rs/execpolicy/tests/basic.rs
+++ b/codex-rs/execpolicy/tests/basic.rs
@@ -64,6 +64,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
evaluation
@@ -71,6 +72,56 @@ prefix_rule(
Ok(())
}
+#[test]
+fn forbidden_reason_is_attached_to_forbidden_matches() -> Result<()> {
+ let policy_src = r#"
+prefix_rule(
+ pattern = ["rm"],
+ decision = "forbidden",
+ forbidden_reason = "destructive command",
+)
+ "#;
+ let mut parser = PolicyParser::new();
+ parser.parse("test.rules", policy_src)?;
+ let policy = parser.build();
+
+ let evaluation = policy.check(
+ &tokens(&["rm", "-rf", "/some/important/folder"]),
+ &allow_all,
+ );
+ assert_eq!(
+ Evaluation {
+ decision: Decision::Forbidden,
+ matched_rules: vec![RuleMatch::PrefixRuleMatch {
+ matched_prefix: tokens(&["rm"]),
+ decision: Decision::Forbidden,
+ forbidden_reason: Some("destructive command".to_string()),
+ }],
+ },
+ evaluation
+ );
+ Ok(())
+}
+
+#[test]
+fn forbidden_reason_requires_forbidden_decision() {
+ let policy_src = r#"
+prefix_rule(
+ pattern = ["ls"],
+ decision = "allow",
+ forbidden_reason = "not allowed here",
+)
+ "#;
+ let mut parser = PolicyParser::new();
+ let err = parser
+ .parse("test.rules", policy_src)
+ .expect_err("expected parse error");
+ assert!(
+ err.to_string()
+ .contains("invalid rule: forbidden_reason requires decision=\"forbidden\"")
+ );
+}
+
#[test]
fn add_prefix_rule_extends_policy() -> Result<()> {
let mut policy = Policy::empty();
@@ -84,17 +135,19 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
rest: vec![PatternToken::Single(String::from("-l"))].into(),
},
decision: Decision::Prompt,
+ forbidden_reason: None,
})],
rules
);
- let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all);
+ let evaluation = policy.check(&tokens(&["ls", "-l", "/some/important/folder"]), &allow_all);
assert_eq!(
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls", "-l"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
}],
},
evaluation
@@ -142,6 +195,7 @@ prefix_rule(
rest: Vec::::new().into(),
},
decision: Decision::Prompt,
+ forbidden_reason: None,
}),
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
@@ -149,6 +203,7 @@ prefix_rule(
rest: vec![PatternToken::Single("commit".to_string())].into(),
},
decision: Decision::Forbidden,
+ forbidden_reason: None,
}),
],
git_rules
@@ -161,6 +216,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
}],
},
status_eval
@@ -174,10 +230,12 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
+ forbidden_reason: None,
},
],
},
@@ -211,6 +269,7 @@ prefix_rule(
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
decision: Decision::Allow,
+ forbidden_reason: None,
})],
bash_rules
);
@@ -221,6 +280,7 @@ prefix_rule(
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
decision: Decision::Allow,
+ forbidden_reason: None,
})],
sh_rules
);
@@ -232,6 +292,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["bash", "-c"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
bash_eval
@@ -244,6 +305,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["sh", "-l"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
sh_eval
@@ -277,6 +339,7 @@ prefix_rule(
.into(),
},
decision: Decision::Allow,
+ forbidden_reason: None,
})],
rules
);
@@ -288,6 +351,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
npm_i
@@ -303,6 +367,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "install", "--no-save"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
npm_install
@@ -332,6 +397,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
+ forbidden_reason: None,
}],
},
match_eval
@@ -378,10 +444,12 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
+ forbidden_reason: None,
},
],
},
@@ -419,14 +487,17 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
+ forbidden_reason: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
+ forbidden_reason: None,
},
],
},
diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml
index 0ac0351333..73803af86a 100644
--- a/codex-rs/feedback/Cargo.toml
+++ b/codex-rs/feedback/Cargo.toml
@@ -8,6 +8,7 @@ license.workspace = true
anyhow = { workspace = true }
codex-protocol = { workspace = true }
sentry = { version = "0.46" }
+tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs
index eaa949717a..2096f4505b 100644
--- a/codex-rs/feedback/src/lib.rs
+++ b/codex-rs/feedback/src/lib.rs
@@ -1,4 +1,6 @@
+use std::collections::BTreeMap;
use std::collections::VecDeque;
+use std::collections::btree_map::Entry;
use std::fs;
use std::io::Write;
use std::io::{self};
@@ -11,12 +13,20 @@ use anyhow::Result;
use anyhow::anyhow;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
+use tracing::Event;
+use tracing::Level;
+use tracing::field::Visit;
+use tracing_subscriber::Layer;
+use tracing_subscriber::filter::Targets;
use tracing_subscriber::fmt::writer::MakeWriter;
+use tracing_subscriber::registry::LookupSpan;
const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB
const SENTRY_DSN: &str =
"https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458";
const UPLOAD_TIMEOUT_SECS: u64 = 10;
+const FEEDBACK_TAGS_TARGET: &str = "feedback_tags";
+const MAX_FEEDBACK_TAGS: usize = 64;
#[derive(Clone)]
pub struct CodexFeedback {
@@ -46,13 +56,50 @@ impl CodexFeedback {
}
}
+ /// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback
+ /// ring buffer.
+ ///
+ /// This is intended for initialization code so call sites don't have to duplicate the exact
+ /// `fmt::layer()` configuration and filter logic.
+ pub fn logger_layer(&self) -> impl Layer + Send + Sync + 'static
+ where
+ S: tracing::Subscriber + for<'a> LookupSpan<'a>,
+ {
+ tracing_subscriber::fmt::layer()
+ .with_writer(self.make_writer())
+ .with_ansi(false)
+ .with_target(false)
+ // Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the
+ // full trace when the user uploads a report.
+ .with_filter(Targets::new().with_default(Level::TRACE))
+ }
+
+ /// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback.
+ ///
+ /// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback
+ /// uploads later.
+ pub fn metadata_layer(&self) -> impl Layer + Send + Sync + 'static
+ where
+ S: tracing::Subscriber + for<'a> LookupSpan<'a>,
+ {
+ FeedbackMetadataLayer {
+ inner: self.inner.clone(),
+ }
+ .with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE))
+ }
+
pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot {
let bytes = {
let guard = self.inner.ring.lock().expect("mutex poisoned");
guard.snapshot_bytes()
};
+ let tags = {
+ let guard = self.inner.tags.lock().expect("mutex poisoned");
+ guard.clone()
+ };
CodexLogSnapshot {
bytes,
+ tags,
thread_id: session_id
.map(|id| id.to_string())
.unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()),
@@ -62,12 +109,14 @@ impl CodexFeedback {
struct FeedbackInner {
ring: Mutex,
+ tags: Mutex>,
}
impl FeedbackInner {
fn new(max_bytes: usize) -> Self {
Self {
ring: Mutex::new(RingBuffer::new(max_bytes)),
+ tags: Mutex::new(BTreeMap::new()),
}
}
}
@@ -152,6 +201,7 @@ impl RingBuffer {
pub struct CodexLogSnapshot {
bytes: Vec,
+ tags: BTreeMap,
pub thread_id: String,
}
@@ -212,6 +262,22 @@ impl CodexLogSnapshot {
tags.insert(String::from("reason"), r.to_string());
}
+ let reserved = [
+ "thread_id",
+ "classification",
+ "cli_version",
+ "session_source",
+ "reason",
+ ];
+ for (key, value) in &self.tags {
+ if reserved.contains(&key.as_str()) {
+ continue;
+ }
+ if let Entry::Vacant(entry) = tags.entry(key.clone()) {
+ entry.insert(value.clone());
+ }
+ }
+
let level = match classification {
"bug" | "bad_result" => Level::Error,
_ => Level::Info,
@@ -280,9 +346,80 @@ fn display_classification(classification: &str) -> String {
}
}
+#[derive(Clone)]
+struct FeedbackMetadataLayer {
+ inner: Arc,
+}
+
+impl Layer for FeedbackMetadataLayer
+where
+ S: tracing::Subscriber + for<'a> LookupSpan<'a>,
+{
+ fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
+ // This layer is filtered by `Targets`, but keep the guard anyway in case it is used without
+ // the filter.
+ if event.metadata().target() != FEEDBACK_TAGS_TARGET {
+ return;
+ }
+
+ let mut visitor = FeedbackTagsVisitor::default();
+ event.record(&mut visitor);
+ if visitor.tags.is_empty() {
+ return;
+ }
+
+ let mut guard = self.inner.tags.lock().expect("mutex poisoned");
+ for (key, value) in visitor.tags {
+ if guard.len() >= MAX_FEEDBACK_TAGS && !guard.contains_key(&key) {
+ continue;
+ }
+ guard.insert(key, value);
+ }
+ }
+}
+
+#[derive(Default)]
+struct FeedbackTagsVisitor {
+ tags: BTreeMap,
+}
+
+impl Visit for FeedbackTagsVisitor {
+ fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
+ self.tags
+ .insert(field.name().to_string(), value.to_string());
+ }
+
+ fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
+ self.tags
+ .insert(field.name().to_string(), value.to_string());
+ }
+
+ fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
+ self.tags
+ .insert(field.name().to_string(), value.to_string());
+ }
+
+ fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
+ self.tags
+ .insert(field.name().to_string(), value.to_string());
+ }
+
+ fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
+ self.tags
+ .insert(field.name().to_string(), value.to_string());
+ }
+
+ fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
+ self.tags
+ .insert(field.name().to_string(), format!("{value:?}"));
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use tracing_subscriber::layer::SubscriberExt;
+ use tracing_subscriber::util::SubscriberInitExt;
#[test]
fn ring_buffer_drops_front_when_full() {
@@ -296,4 +433,18 @@ mod tests {
// Capacity 8: after writing 10 bytes, we should keep the last 8.
pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij");
}
+
+ #[test]
+ fn metadata_layer_records_tags_from_feedback_target() {
+ let fb = CodexFeedback::new();
+ let _guard = tracing_subscriber::registry()
+ .with(fb.metadata_layer())
+ .set_default();
+
+ tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags");
+
+ let snap = fb.snapshot(None);
+ pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5"));
+ pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
+ }
}
diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml
index 8c99326a4c..a703808e80 100644
--- a/codex-rs/otel/Cargo.toml
+++ b/codex-rs/otel/Cargo.toml
@@ -44,11 +44,6 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum_macros = { workspace = true }
tokio = { workspace = true }
-tonic = { workspace = true, features = [
- "transport",
- "tls-native-roots",
- "tls-ring",
-] }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
diff --git a/codex-rs/otel/src/otel_manager.rs b/codex-rs/otel/src/otel_manager.rs
index 33750d83c5..fbdd322722 100644
--- a/codex-rs/otel/src/otel_manager.rs
+++ b/codex-rs/otel/src/otel_manager.rs
@@ -70,7 +70,7 @@ impl OtelManager {
let session_span = trace_span!("new_session", conversation_id = %conversation_id, session_source = %session_source);
if let Some(context) = traceparent_context_from_env() {
- session_span.set_parent(context);
+ let _ = session_span.set_parent(context);
}
Self {
@@ -511,6 +511,7 @@ impl OtelManager {
"reasoning_summary_part_added".into()
}
ResponseEvent::RateLimits(_) => "rate_limits".into(),
+ ResponseEvent::ModelsEtag(_) => "models_etag".into(),
}
}
diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs
index b9d9559325..8a777e7fdd 100644
--- a/codex-rs/otel/src/otel_provider.rs
+++ b/codex-rs/otel/src/otel_provider.rs
@@ -22,6 +22,10 @@ use opentelemetry_otlp::SpanExporter;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_otlp::WithHttpConfig;
use opentelemetry_otlp::WithTonicConfig;
+use opentelemetry_otlp::tonic_types::metadata::MetadataMap;
+use opentelemetry_otlp::tonic_types::transport::Certificate as TonicCertificate;
+use opentelemetry_otlp::tonic_types::transport::ClientTlsConfig;
+use opentelemetry_otlp::tonic_types::transport::Identity as TonicIdentity;
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
@@ -44,10 +48,6 @@ use std::io::{self};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
-use tonic::metadata::MetadataMap;
-use tonic::transport::Certificate as TonicCertificate;
-use tonic::transport::ClientTlsConfig;
-use tonic::transport::Identity as TonicIdentity;
use tracing::debug;
use tracing::level_filters::LevelFilter;
use tracing::warn;
@@ -102,7 +102,7 @@ impl OtelProvider {
.map(|provider| provider.tracer(settings.service_name.clone()));
if let Some(provider) = tracer_provider.clone() {
- let _ = global::set_tracer_provider(provider);
+ global::set_tracer_provider(provider);
global::set_text_map_propagator(TraceContextPropagator::new());
}
if tracer.is_some() {
diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs
index 722e915615..5113dadd05 100644
--- a/codex-rs/protocol/src/models.rs
+++ b/codex-rs/protocol/src/models.rs
@@ -416,7 +416,6 @@ impl Serialize for FunctionCallOutputPayload {
where
S: Serializer,
{
- tracing::debug!("Function call output payload: {:?}", self);
if let Some(items) = &self.content_items {
items.serialize(serializer)
} else {
diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs
index f2fc08fcdb..ae426e6296 100644
--- a/codex-rs/protocol/src/openai_models.rs
+++ b/codex-rs/protocol/src/openai_models.rs
@@ -187,8 +187,6 @@ pub struct ModelInfo {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
pub struct ModelsResponse {
pub models: Vec,
- #[serde(default)]
- pub etag: String,
}
// convert ModelInfo to ModelPreset
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index f2e31c9032..4896108629 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -844,7 +844,7 @@ pub struct TaskStartedEvent {
pub model_context_window: Option,
}
-#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema, TS)]
+#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
pub struct TokenUsage {
#[ts(type = "number")]
pub input_tokens: i64,
@@ -858,7 +858,7 @@ pub struct TokenUsage {
pub total_tokens: i64,
}
-#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,
pub last_token_usage: TokenUsage,
@@ -1600,6 +1600,11 @@ pub struct StreamErrorEvent {
pub message: String,
#[serde(default)]
pub codex_error_info: Option,
+ /// Optional details about the underlying stream failure (often the same
+ /// human-readable message that is surfaced as the terminal error if retries
+ /// are exhausted).
+ #[serde(default)]
+ pub additional_details: Option,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index c91eafcbe7..e0af51e7eb 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -372,10 +372,6 @@ impl App {
}
let enhanced_keys_supported = tui.enhanced_keys_supported();
- let model_family = conversation_manager
- .get_models_manager()
- .construct_model_family(model.as_str(), &config)
- .await;
let mut chat_widget = match resume_selection {
ResumeSelection::StartFresh | ResumeSelection::Exit => {
let init = crate::chatwidget::ChatWidgetInit {
@@ -389,7 +385,7 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
- model_family: model_family.clone(),
+ model: model.clone(),
};
ChatWidget::new(init, conversation_manager.clone())
}
@@ -415,7 +411,7 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
- model_family: model_family.clone(),
+ model: model.clone(),
};
ChatWidget::new_from_existing(
init,
@@ -582,7 +578,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.current_model = model_family.get_model_slug().to_string();
@@ -632,7 +628,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
@@ -767,12 +763,7 @@ impl App {
self.on_update_reasoning_effort(effort);
}
AppEvent::UpdateModel(model) => {
- let model_family = self
- .server
- .get_models_manager()
- .construct_model_family(&model, &self.config)
- .await;
- self.chat_widget.set_model(&model, model_family);
+ self.chat_widget.set_model(&model);
self.current_model = model;
}
AppEvent::OpenReasoningPopup { model } => {
@@ -1357,7 +1348,7 @@ mod tests {
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
- let current_model = chat_widget.get_model_family().get_model_slug().to_string();
+ let current_model = "gpt-5.2-codex".to_string();
let server = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
@@ -1396,7 +1387,7 @@ mod tests {
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
- let current_model = chat_widget.get_model_family().get_model_slug().to_string();
+ let current_model = "gpt-5.2-codex".to_string();
let server = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs
index 671702d308..ce5dff2ed8 100644
--- a/codex-rs/tui/src/app_backtrack.rs
+++ b/codex-rs/tui/src/app_backtrack.rs
@@ -338,10 +338,9 @@ impl App {
) {
let conv = new_conv.conversation;
let session_configured = new_conv.session_configured;
- let model_family = self.chat_widget.get_model_family();
let init = crate::chatwidget::ChatWidgetInit {
config: cfg,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
@@ -354,7 +353,6 @@ impl App {
};
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
- self.current_model = model_family.get_model_slug().to_string();
// Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(nth_user_message);
self.render_transcript_once(tui);
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 4578f58249..f4418ceadb 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -15,7 +15,6 @@ use codex_core::features::Feature;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::models_manager::manager::ModelsManager;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -291,7 +290,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) models_manager: Arc,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
- pub(crate) model_family: ModelFamily,
+ pub(crate) model: String,
}
#[derive(Default)]
@@ -316,7 +315,7 @@ pub(crate) struct ChatWidget {
bottom_pane: BottomPane,
active_cell: Option>,
config: Config,
- model_family: ModelFamily,
+ model: String,
auth_manager: Arc,
models_manager: Arc,
session_header: SessionHeader,
@@ -608,12 +607,10 @@ impl ChatWidget {
}
fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option {
- info.model_context_window
- .or(self.model_family.context_window)
- .map(|window| {
- info.last_token_usage
- .percent_of_context_window_remaining(window)
- })
+ info.model_context_window.map(|window| {
+ info.last_token_usage
+ .percent_of_context_window_remaining(window)
+ })
}
fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option {
@@ -681,7 +678,7 @@ impl ChatWidget {
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
- && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG
+ && self.model != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
@@ -715,9 +712,6 @@ impl ChatWidget {
self.stream_controller = None;
self.maybe_show_pending_rate_limit_prompt();
}
- pub(crate) fn get_model_family(&self) -> ModelFamily {
- self.model_family.clone()
- }
fn on_error(&mut self, message: String) {
self.finalize_turn();
@@ -1099,11 +1093,11 @@ impl ChatWidget {
}
}
- fn on_stream_error(&mut self, message: String) {
+ fn on_stream_error(&mut self, message: String, additional_details: Option) {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status_header.clone());
}
- self.set_status_header(message);
+ self.set_status(message, additional_details);
}
/// Periodic tick to commit at most one queued line to history with a small delay,
@@ -1420,11 +1414,10 @@ impl ChatWidget {
models_manager,
feedback,
is_first_run,
- model_family,
+ model,
} = common;
- let model_slug = model_family.get_model_slug().to_string();
let mut config = config;
- config.model = Some(model_slug.clone());
+ config.model = Some(model.clone());
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
@@ -1445,10 +1438,10 @@ impl ChatWidget {
}),
active_cell: None,
config,
- model_family,
+ model: model.clone(),
auth_manager,
models_manager,
- session_header: SessionHeader::new(model_slug),
+ session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1506,10 +1499,9 @@ impl ChatWidget {
auth_manager,
models_manager,
feedback,
- model_family,
+ model,
..
} = common;
- let model_slug = model_family.get_model_slug().to_string();
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
@@ -1532,10 +1524,10 @@ impl ChatWidget {
}),
active_cell: None,
config,
- model_family,
+ model: model.clone(),
auth_manager,
models_manager,
- session_header: SessionHeader::new(model_slug),
+ session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -2102,9 +2094,11 @@ impl ChatWidget {
}
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
- EventMsg::StreamError(StreamErrorEvent { message, .. }) => {
- self.on_stream_error(message)
- }
+ EventMsg::StreamError(StreamErrorEvent {
+ message,
+ additional_details,
+ ..
+ }) => self.on_stream_error(message, additional_details),
EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);
@@ -2247,22 +2241,20 @@ impl ChatWidget {
pub(crate) fn add_status_output(&mut self) {
let default_usage = TokenUsage::default();
- let (total_usage, context_usage) = if let Some(ti) = &self.token_info {
- (&ti.total_token_usage, Some(&ti.last_token_usage))
- } else {
- (&default_usage, Some(&default_usage))
- };
+ let token_info = self.token_info.as_ref();
+ let total_usage = token_info
+ .map(|ti| &ti.total_token_usage)
+ .unwrap_or(&default_usage);
self.add_to_history(crate::status::new_status_output(
&self.config,
self.auth_manager.as_ref(),
- &self.model_family,
+ token_info,
total_usage,
- context_usage,
&self.conversation_id,
self.rate_limit_snapshot.as_ref(),
self.plan_type,
Local::now(),
- self.model_family.get_model_slug(),
+ &self.model,
));
}
@@ -2415,7 +2407,6 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
- let current_model = self.model_family.get_model_slug().to_string();
let presets: Vec =
// todo(aibrahim): make this async function
match self.models_manager.try_list_models(&self.config) {
@@ -2432,9 +2423,9 @@ impl ChatWidget {
let current_label = presets
.iter()
- .find(|preset| preset.model == current_model)
+ .find(|preset| preset.model == self.model)
.map(|preset| preset.display_name.to_string())
- .unwrap_or_else(|| current_model.clone());
+ .unwrap_or_else(|| self.model.clone());
let (mut auto_presets, other_presets): (Vec, Vec) = presets
.into_iter()
@@ -2460,7 +2451,7 @@ impl ChatWidget {
SelectionItem {
name: preset.display_name.clone(),
description,
- is_current: model == current_model,
+ is_current: model == self.model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
@@ -2523,12 +2514,11 @@ impl ChatWidget {
return;
}
- let current_model = self.model_family.get_model_slug().to_string();
let mut items: Vec = Vec::new();
for preset in presets.into_iter() {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
- let is_current = preset.model == current_model;
+ let is_current = preset.model == self.model;
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let preset_for_action = preset.clone();
let actions: Vec = vec![Box::new(move |tx| {
@@ -2654,7 +2644,7 @@ impl ChatWidget {
.or(Some(default_effort));
let model_slug = preset.model.to_string();
- let is_current_model = self.model_family.get_model_slug() == preset.model;
+ let is_current_model = self.model == preset.model;
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
@@ -3244,9 +3234,9 @@ impl ChatWidget {
}
/// Set the model in the widget's config copy.
- pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) {
+ pub(crate) fn set_model(&mut self, model: &str) {
self.session_header.set_model(model);
- self.model_family = model_family;
+ self.model = model.to_string();
}
pub(crate) fn add_info_message(&mut self, message: String, hint: Option) {
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index ae3e7abd6a..a0ff8d42e9 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -313,7 +313,6 @@ async fn helpers_are_available_and_do_not_panic() {
let tx = AppEventSender::new(tx_raw);
let cfg = test_config().await;
let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref());
- let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg);
let conversation_manager = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("test"),
cfg.model_provider.clone(),
@@ -330,7 +329,7 @@ async fn helpers_are_available_and_do_not_panic() {
models_manager: conversation_manager.get_models_manager(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
- model_family,
+ model: resolved_model,
};
let mut w = ChatWidget::new(init, conversation_manager);
// Basic construction sanity.
@@ -371,11 +370,11 @@ async fn make_chatwidget_manual(
codex_op_tx: op_tx,
bottom_pane: bottom,
active_cell: None,
- config: cfg.clone(),
- model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg),
+ config: cfg,
+ model: resolved_model.clone(),
auth_manager: auth_manager.clone(),
models_manager: Arc::new(ModelsManager::new(auth_manager)),
- session_header: SessionHeader::new(resolved_model.clone()),
+ session_header: SessionHeader::new(resolved_model),
initial_user_message: None,
token_info: None,
rate_limit_snapshot: None,
@@ -3223,11 +3222,13 @@ async fn stream_error_updates_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
let msg = "Reconnecting... 2/5";
+ let details = "Idle timeout waiting for SSE";
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: msg.to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
+ additional_details: Some(details.to_string()),
}),
});
@@ -3241,6 +3242,7 @@ async fn stream_error_updates_status_indicator() {
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
+ assert_eq!(status.details(), Some(details));
}
#[tokio::test]
@@ -3277,6 +3279,7 @@ async fn stream_recovery_restores_previous_status_header() {
msg: EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
+ additional_details: None,
}),
});
drain_insert_history(&mut rx);
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index bce9a350f4..6b784affce 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -29,7 +29,6 @@ use std::path::PathBuf;
use tracing::error;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
-use tracing_subscriber::filter::Targets;
use tracing_subscriber::prelude::*;
mod additional_dirs;
@@ -282,13 +281,8 @@ pub async fn run_main(
.with_filter(env_filter());
let feedback = codex_feedback::CodexFeedback::new();
- let targets = Targets::new().with_default(tracing::Level::TRACE);
-
- let feedback_layer = tracing_subscriber::fmt::layer()
- .with_writer(feedback.make_writer())
- .with_ansi(false)
- .with_target(false)
- .with_filter(targets);
+ let feedback_layer = feedback.logger_layer();
+ let feedback_metadata_layer = feedback.metadata_layer();
if cli.oss && model_provider_override.is_some() {
// We're in the oss section, so provider_id should be Some
@@ -323,6 +317,7 @@ pub async fn run_main(
let _ = tracing_subscriber::registry()
.with(file_layer)
.with(feedback_layer)
+ .with(feedback_metadata_layer)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs
index 429134362a..07cd5a1988 100644
--- a/codex-rs/tui/src/status/card.rs
+++ b/codex-rs/tui/src/status/card.rs
@@ -7,10 +7,10 @@ use chrono::DateTime;
use chrono::Local;
use codex_common::create_config_summary_entries;
use codex_core::config::Config;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
+use codex_core::protocol::TokenUsageInfo;
use codex_protocol::ConversationId;
use codex_protocol::account::PlanType;
use ratatui::prelude::*;
@@ -72,9 +72,8 @@ struct StatusHistoryCell {
pub(crate) fn new_status_output(
config: &Config,
auth_manager: &AuthManager,
- model_family: &ModelFamily,
+ token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
- context_usage: Option<&TokenUsage>,
session_id: &Option,
rate_limits: Option<&RateLimitSnapshotDisplay>,
plan_type: Option,
@@ -85,9 +84,8 @@ pub(crate) fn new_status_output(
let card = StatusHistoryCell::new(
config,
auth_manager,
- model_family,
+ token_info,
total_usage,
- context_usage,
session_id,
rate_limits,
plan_type,
@@ -103,9 +101,8 @@ impl StatusHistoryCell {
fn new(
config: &Config,
auth_manager: &AuthManager,
- model_family: &ModelFamily,
+ token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
- context_usage: Option<&TokenUsage>,
session_id: &Option,
rate_limits: Option<&RateLimitSnapshotDisplay>,
plan_type: Option,
@@ -134,12 +131,15 @@ impl StatusHistoryCell {
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(auth_manager, plan_type);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
- let context_window = model_family.context_window.and_then(|window| {
- context_usage.map(|usage| StatusContextWindowData {
- percent_remaining: usage.percent_of_context_window_remaining(window),
- tokens_in_context: usage.tokens_in_context_window(),
- window,
- })
+ let default_usage = TokenUsage::default();
+ let (context_usage, context_window) = match token_info {
+ Some(info) => (&info.last_token_usage, info.model_context_window),
+ None => (&default_usage, config.model_context_window),
+ };
+ let context_window = context_window.map(|window| StatusContextWindowData {
+ percent_remaining: context_usage.percent_of_context_window_remaining(window),
+ tokens_in_context: context_usage.tokens_in_context_window(),
+ window,
});
let token_usage = StatusTokenUsageData {
@@ -348,6 +348,7 @@ impl HistoryCell for StatusHistoryCell {
if self.token_usage.context_window.is_some() {
push_label(&mut labels, &mut seen, "Context window");
}
+
self.collect_rate_limit_labels(&mut seen, &mut labels);
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs
index 317a3d3270..c6f6c73599 100644
--- a/codex-rs/tui/src/status/tests.rs
+++ b/codex-rs/tui/src/status/tests.rs
@@ -8,12 +8,12 @@ use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::models_manager::manager::ModelsManager;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::protocol::CreditsSnapshot;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
+use codex_core::protocol::TokenUsageInfo;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ReasoningEffort;
use insta::assert_snapshot;
@@ -37,8 +37,15 @@ fn test_auth_manager(config: &Config) -> AuthManager {
)
}
-fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily {
- ModelsManager::construct_model_family_offline(model_slug, config)
+fn token_info_for(model_slug: &str, config: &Config, usage: &TokenUsage) -> TokenUsageInfo {
+ let context_window = ModelsManager::construct_model_family_offline(model_slug, config)
+ .context_window
+ .or(config.model_context_window);
+ TokenUsageInfo {
+ total_token_usage: usage.clone(),
+ last_token_usage: usage.clone(),
+ model_context_window: context_window,
+ }
}
fn render_lines(lines: &[Line<'static>]) -> Vec {
@@ -132,14 +139,13 @@ async fn status_snapshot_includes_reasoning_details() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -190,13 +196,12 @@ async fn status_snapshot_includes_monthly_limit() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -235,13 +240,12 @@ async fn status_snapshot_shows_unlimited_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -279,13 +283,12 @@ async fn status_snapshot_shows_positive_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -323,13 +326,12 @@ async fn status_snapshot_hides_zero_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -365,13 +367,12 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -407,13 +408,12 @@ async fn status_card_token_usage_excludes_cached_tokens() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
None,
None,
@@ -464,13 +464,12 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -510,13 +509,12 @@ async fn status_snapshot_shows_missing_limits_message() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
None,
None,
@@ -574,13 +572,12 @@ async fn status_snapshot_includes_credits_and_limits() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -626,13 +623,12 @@ async fn status_snapshot_shows_empty_limits_message() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -687,13 +683,12 @@ async fn status_snapshot_shows_stale_limits_message() {
let now = captured_at + ChronoDuration::minutes(20);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -752,13 +747,12 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
let now = captured_at + ChronoDuration::minutes(20);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -803,13 +797,16 @@ async fn status_context_window_uses_last_usage() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = TokenUsageInfo {
+ total_token_usage: total_usage.clone(),
+ last_token_usage: last_usage,
+ model_context_window: config.model_context_window,
+ };
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&total_usage,
- Some(&last_usage),
&None,
None,
None,
diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs
index 5c6f32cd9e..6349c007eb 100644
--- a/codex-rs/tui/src/terminal_palette.rs
+++ b/codex-rs/tui/src/terminal_palette.rs
@@ -1,5 +1,13 @@
use crate::color::perceptual_distance;
use ratatui::style::Color;
+use std::sync::atomic::AtomicU64;
+use std::sync::atomic::Ordering;
+
+static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0);
+
+fn bump_palette_version() {
+ DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
+}
/// Returns the closest color to the target color that the terminal can display.
pub fn best_color(target: (u8, u8, u8)) -> Color {
@@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color {
pub fn requery_default_colors() {
imp::requery_default_colors();
+ bump_palette_version();
}
#[derive(Clone, Copy)]
@@ -47,6 +56,14 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.bg)
}
+/// Returns a monotonic counter that increments whenever `requery_default_colors()` runs
+/// successfully so cached renderers can know when their styling assumptions (e.g.
+/// background colors baked into cached transcript rows) are stale and need invalidation.
+#[allow(dead_code)]
+pub fn palette_version() -> u64 {
+ DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
+}
+
#[cfg(all(unix, not(test)))]
mod imp {
use super::DefaultColors;
diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml
index eb4e9cebde..3108e5561e 100644
--- a/codex-rs/tui2/Cargo.toml
+++ b/codex-rs/tui2/Cargo.toml
@@ -62,6 +62,7 @@ ratatui = { workspace = true, features = [
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
+ratatui-core = { workspace = true }
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { version = "0.12", features = ["json"] }
@@ -73,6 +74,7 @@ strum_macros = { workspace = true }
supports-color = { workspace = true }
tempfile = { workspace = true }
textwrap = { workspace = true }
+tui-scrollbar = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs
index d567ce9617..677d73d71e 100644
--- a/codex-rs/tui2/src/app.rs
+++ b/codex-rs/tui2/src/app.rs
@@ -3,7 +3,6 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget;
-use crate::clipboard_copy;
use crate::custom_terminal::Frame;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
@@ -17,11 +16,19 @@ use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
+use crate::transcript_copy_action::TranscriptCopyAction;
+use crate::transcript_copy_action::TranscriptCopyFeedback;
use crate::transcript_copy_ui::TranscriptCopyUi;
use crate::transcript_multi_click::TranscriptMultiClick;
+use crate::transcript_scrollbar::render_transcript_scrollbar_if_active;
+use crate::transcript_scrollbar::split_transcript_area;
+use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent;
+use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling;
+use crate::transcript_scrollbar_ui::TranscriptScrollbarUi;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
+use crate::transcript_view_cache::TranscriptViewCache;
use crate::tui;
use crate::tui::TuiEvent;
use crate::tui::scrolling::MouseScrollState;
@@ -29,7 +36,6 @@ use crate::tui::scrolling::ScrollConfig;
use crate::tui::scrolling::ScrollConfigOverrides;
use crate::tui::scrolling::ScrollDirection;
use crate::tui::scrolling::ScrollUpdate;
-use crate::tui::scrolling::TranscriptLineMeta;
use crate::tui::scrolling::TranscriptScroll;
use crate::update_action::UpdateAction;
use codex_ansi_escape::ansi_escape_line;
@@ -326,6 +332,7 @@ pub(crate) struct App {
pub(crate) file_search: FileSearchManager,
pub(crate) transcript_cells: Vec>,
+ transcript_view_cache: TranscriptViewCache,
#[allow(dead_code)]
transcript_scroll: TranscriptScroll,
@@ -334,6 +341,8 @@ pub(crate) struct App {
transcript_view_top: usize,
transcript_total_lines: usize,
transcript_copy_ui: TranscriptCopyUi,
+ transcript_copy_action: TranscriptCopyAction,
+ transcript_scrollbar_ui: TranscriptScrollbarUi,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option,
@@ -410,10 +419,6 @@ impl App {
}
let enhanced_keys_supported = tui.enhanced_keys_supported();
- let model_family = conversation_manager
- .get_models_manager()
- .construct_model_family(model.as_str(), &config)
- .await;
let mut chat_widget = match resume_selection {
ResumeSelection::StartFresh | ResumeSelection::Exit => {
let init = crate::chatwidget::ChatWidgetInit {
@@ -427,7 +432,7 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
- model_family: model_family.clone(),
+ model: model.clone(),
};
ChatWidget::new(init, conversation_manager.clone())
}
@@ -453,7 +458,7 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
- model_family: model_family.clone(),
+ model: model.clone(),
};
ChatWidget::new_from_existing(
init,
@@ -496,12 +501,15 @@ impl App {
file_search,
enhanced_keys_supported,
transcript_cells: Vec::new(),
+ transcript_view_cache: TranscriptViewCache::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
+ transcript_copy_action: TranscriptCopyAction::default(),
+ transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -669,11 +677,14 @@ impl App {
self.transcript_total_lines,
))
};
+ let copy_selection_key = self.copy_selection_key();
+ let copy_feedback = self.transcript_copy_feedback_for_footer();
self.chat_widget.set_transcript_ui_state(
transcript_scrolled,
selection_active,
scroll_position,
- self.copy_selection_key(),
+ copy_selection_key,
+ copy_feedback,
);
}
}
@@ -704,35 +715,33 @@ impl App {
return area.y;
}
- let transcript_area = Rect {
+ let transcript_full_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: max_transcript_height,
};
+ let (transcript_area, _) = split_transcript_area(transcript_full_area);
- let transcript =
- crate::transcript_render::build_wrapped_transcript_lines(cells, transcript_area.width);
- let (lines, line_meta) = (transcript.lines, transcript.meta);
- if lines.is_empty() {
- Clear.render_ref(transcript_area, frame.buffer);
+ self.transcript_view_cache
+ .ensure_wrapped(cells, transcript_area.width);
+ let total_lines = self.transcript_view_cache.lines().len();
+ if total_lines == 0 {
+ Clear.render_ref(transcript_full_area, frame.buffer);
self.transcript_scroll = TranscriptScroll::default();
self.transcript_view_top = 0;
self.transcript_total_lines = 0;
return area.y;
}
- let is_user_cell: Vec = cells
- .iter()
- .map(|c| c.as_any().is::())
- .collect();
-
- let total_lines = lines.len();
self.transcript_total_lines = total_lines;
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
let max_start = total_lines.saturating_sub(max_visible);
- let (scroll_state, top_offset) = self.transcript_scroll.resolve_top(&line_meta, max_start);
+ let (scroll_state, top_offset) = {
+ let line_meta = self.transcript_view_cache.line_meta();
+ self.transcript_scroll.resolve_top(line_meta, max_start)
+ };
self.transcript_scroll = scroll_state;
self.transcript_view_top = top_offset;
@@ -759,12 +768,19 @@ impl App {
);
}
- let transcript_area = Rect {
+ let transcript_full_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: transcript_visible_height,
};
+ let (transcript_area, transcript_scrollbar_area) =
+ split_transcript_area(transcript_full_area);
+
+ // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply
+ // copy already-rendered `Cell`s instead of re-running grapheme segmentation.
+ self.transcript_view_cache
+ .set_raster_capacity(max_visible.saturating_mul(4).max(256));
for (row_index, line_index) in (top_offset..total_lines).enumerate() {
if row_index >= max_visible {
@@ -779,21 +795,8 @@ impl App {
height: 1,
};
- let is_user_row = line_meta
- .get(line_index)
- .and_then(TranscriptLineMeta::cell_index)
- .map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
- .unwrap_or(false);
- if is_user_row {
- let base_style = crate::style::user_message_style();
- for x in row_area.x..row_area.right() {
- let cell = &mut frame.buffer[(x, y)];
- let style = cell.style().patch(base_style);
- cell.set_style(style);
- }
- }
-
- lines[line_index].render_ref(row_area, frame.buffer);
+ self.transcript_view_cache
+ .render_row_index_into(line_index, row_area, frame.buffer);
}
self.apply_transcript_selection(transcript_area, frame.buffer);
@@ -813,6 +816,13 @@ impl App {
} else {
self.transcript_copy_ui.clear_affordance();
}
+ render_transcript_scrollbar_if_active(
+ frame.buffer,
+ transcript_scrollbar_area,
+ total_lines,
+ max_visible,
+ top_offset,
+ );
chat_top
}
@@ -861,21 +871,45 @@ impl App {
return;
}
- let transcript_area = Rect {
+ let transcript_full_area = Rect {
x: 0,
y: 0,
width,
height: transcript_height,
};
+ let (transcript_area, transcript_scrollbar_area) =
+ split_transcript_area(transcript_full_area);
let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
let max_x = transcript_area.right().saturating_sub(1);
+ if matches!(
+ self.transcript_scrollbar_ui
+ .handle_mouse_event(TranscriptScrollbarMouseEvent {
+ tui,
+ mouse_event,
+ transcript_area,
+ scrollbar_area: transcript_scrollbar_area,
+ transcript_cells: &self.transcript_cells,
+ transcript_view_cache: &mut self.transcript_view_cache,
+ transcript_scroll: &mut self.transcript_scroll,
+ transcript_view_top: &mut self.transcript_view_top,
+ transcript_total_lines: &mut self.transcript_total_lines,
+ mouse_scroll_state: &mut self.scroll_state,
+ }),
+ TranscriptScrollbarMouseHandling::Handled
+ ) {
+ return;
+ }
+
// Treat the transcript as the only interactive region for transcript selection.
//
// This prevents clicks in the composer/footer from starting or extending a transcript
// selection, while still allowing a left-click outside the transcript to clear an
// existing highlight.
- if mouse_event.row < transcript_area.y || mouse_event.row >= transcript_area.bottom() {
+ if !self.transcript_scrollbar_ui.pointer_capture_active()
+ && (mouse_event.row < transcript_full_area.y
+ || mouse_event.row >= transcript_full_area.bottom())
+ {
if matches!(
mouse_event.kind,
MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
@@ -906,7 +940,14 @@ impl App {
.transcript_copy_ui
.hit_test(mouse_event.column, mouse_event.row)
{
- self.copy_transcript_selection(tui);
+ if self.transcript_copy_action.copy_and_handle(
+ tui,
+ chat_height,
+ &self.transcript_cells,
+ self.transcript_selection,
+ ) {
+ self.transcript_selection = TranscriptSelection::default();
+ }
return;
}
@@ -1082,7 +1123,15 @@ impl App {
return None;
}
- Some((transcript_height as usize, width))
+ let transcript_full_area = Rect {
+ x: 0,
+ y: 0,
+ width,
+ height: transcript_height,
+ };
+ let (transcript_area, _) = split_transcript_area(transcript_full_area);
+
+ Some((transcript_height as usize, transcript_area.width))
}
/// Scroll the transcript by a number of visual lines.
@@ -1106,12 +1155,12 @@ impl App {
return;
}
- let transcript =
- crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
- let line_meta = transcript.meta;
+ self.transcript_view_cache
+ .ensure_wrapped(&self.transcript_cells, width);
+ let line_meta = self.transcript_view_cache.line_meta();
self.transcript_scroll =
self.transcript_scroll
- .scrolled_by(delta_lines, &line_meta, visible_lines);
+ .scrolled_by(delta_lines, line_meta, visible_lines);
if schedule_frame {
// Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps.
@@ -1131,9 +1180,10 @@ impl App {
return;
}
- let transcript =
- crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
- let (lines, line_meta) = (transcript.lines, transcript.meta);
+ self.transcript_view_cache
+ .ensure_wrapped(&self.transcript_cells, width);
+ let lines = self.transcript_view_cache.lines();
+ let line_meta = self.transcript_view_cache.line_meta();
if lines.is_empty() || line_meta.is_empty() {
return;
}
@@ -1147,13 +1197,14 @@ impl App {
let max_start = total_lines.saturating_sub(max_visible);
let top_offset = match self.transcript_scroll {
TranscriptScroll::ToBottom => max_start,
- TranscriptScroll::Scrolled { .. } => {
+ TranscriptScroll::Scrolled { .. }
+ | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => {
// Already anchored; nothing to lock.
return;
}
};
- if let Some(scroll_state) = TranscriptScroll::anchor_for(&line_meta, top_offset) {
+ if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) {
self.transcript_scroll = scroll_state;
}
}
@@ -1250,46 +1301,8 @@ impl App {
}
}
- /// Copy the currently selected transcript region to the system clipboard.
- ///
- /// The selection is defined in terms of flattened wrapped transcript line
- /// indices and columns, and this method reconstructs the same wrapped
- /// transcript used for on-screen rendering so the copied text closely
- /// matches the highlighted region.
- ///
- /// Important: copy operates on the selection's full content-relative range,
- /// not just the current viewport. A selection can extend outside the visible
- /// region (for example, by scrolling after selecting, or by selecting while
- /// autoscrolling), and we still want the clipboard payload to reflect the
- /// entire selected transcript.
- fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
- let size = tui.terminal.last_known_screen_size;
- let width = size.width;
- let height = size.height;
- if width == 0 || height == 0 {
- return;
- }
-
- let chat_height = self.chat_widget.desired_height(width);
- if chat_height >= height {
- return;
- }
-
- let transcript_height = height.saturating_sub(chat_height);
- if transcript_height == 0 {
- return;
- }
-
- let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
- &self.transcript_cells,
- self.transcript_selection,
- width,
- ) else {
- return;
- };
- if let Err(err) = clipboard_copy::copy_text(text) {
- tracing::error!(error = %err, "failed to copy selection to clipboard");
- }
+ fn transcript_copy_feedback_for_footer(&mut self) -> Option {
+ self.transcript_copy_action.footer_feedback()
}
fn copy_selection_key(&self) -> crate::key_hint::KeyBinding {
@@ -1328,11 +1341,6 @@ impl App {
}
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result {
- let model_family = self
- .server
- .get_models_manager()
- .construct_model_family(self.current_model.as_str(), &self.config)
- .await;
match event {
AppEvent::NewSession => {
let summary = session_summary(
@@ -1351,10 +1359,9 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
- self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
@@ -1401,14 +1408,13 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
resumed.conversation,
resumed.session_configured,
);
- self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec> =
vec![summary.usage_line.clone().into()];
@@ -1534,12 +1540,7 @@ impl App {
self.on_update_reasoning_effort(effort);
}
AppEvent::UpdateModel(model) => {
- let model_family = self
- .server
- .get_models_manager()
- .construct_model_family(&model, &self.config)
- .await;
- self.chat_widget.set_model(&model, model_family);
+ self.chat_widget.set_model(&model);
self.current_model = model;
}
AppEvent::OpenReasoningPopup { model } => {
@@ -1925,7 +1926,22 @@ impl App {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if self.transcript_copy_ui.is_copy_key(ch, modifiers) => {
- self.copy_transcript_selection(tui);
+ let size = tui.terminal.last_known_screen_size;
+ let width = size.width;
+ let height = size.height;
+ if width == 0 || height == 0 {
+ return;
+ }
+
+ let chat_height = self.chat_widget.desired_height(width);
+ if self.transcript_copy_action.copy_and_handle(
+ tui,
+ chat_height,
+ &self.transcript_cells,
+ self.transcript_selection,
+ ) {
+ self.transcript_selection = TranscriptSelection::default();
+ }
}
KeyEvent {
code: KeyCode::PageUp,
@@ -2069,6 +2085,7 @@ mod tests {
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use crate::transcript_copy_ui::CopySelectionShortcut;
+ use crate::tui::scrolling::TranscriptLineMeta;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
@@ -2087,7 +2104,7 @@ mod tests {
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
- let current_model = chat_widget.get_model_family().get_model_slug().to_string();
+ let current_model = "gpt-5.2-codex".to_string();
let server = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
@@ -2106,6 +2123,7 @@ mod tests {
active_profile: None,
file_search,
transcript_cells: Vec::new(),
+ transcript_view_cache: TranscriptViewCache::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
@@ -2114,6 +2132,8 @@ mod tests {
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
+ transcript_copy_action: TranscriptCopyAction::default(),
+ transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -2136,7 +2156,7 @@ mod tests {
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
- let current_model = chat_widget.get_model_family().get_model_slug().to_string();
+ let current_model = "gpt-5.2-codex".to_string();
let server = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
@@ -2156,6 +2176,7 @@ mod tests {
active_profile: None,
file_search,
transcript_cells: Vec::new(),
+ transcript_view_cache: TranscriptViewCache::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
@@ -2164,6 +2185,8 @@ mod tests {
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
+ transcript_copy_action: TranscriptCopyAction::default(),
+ transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs
index 671702d308..ce5dff2ed8 100644
--- a/codex-rs/tui2/src/app_backtrack.rs
+++ b/codex-rs/tui2/src/app_backtrack.rs
@@ -338,10 +338,9 @@ impl App {
) {
let conv = new_conv.conversation;
let session_configured = new_conv.session_configured;
- let model_family = self.chat_widget.get_model_family();
let init = crate::chatwidget::ChatWidgetInit {
config: cfg,
- model_family: model_family.clone(),
+ model: self.current_model.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
@@ -354,7 +353,6 @@ impl App {
};
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
- self.current_model = model_family.get_model_slug().to_string();
// Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(nth_user_message);
self.render_transcript_once(tui);
diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs
index 3d5de81a9f..0073173fdc 100644
--- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs
@@ -1,6 +1,7 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
+use crate::transcript_copy_action::TranscriptCopyFeedback;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -124,6 +125,7 @@ pub(crate) struct ChatComposer {
transcript_selection_active: bool,
transcript_scroll_position: Option<(usize, usize)>,
transcript_copy_selection_key: KeyBinding,
+ transcript_copy_feedback: Option,
skills: Option>,
dismissed_skill_popup_token: Option,
}
@@ -176,6 +178,7 @@ impl ChatComposer {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
skills: None,
dismissed_skill_popup_token: None,
};
@@ -1545,6 +1548,7 @@ impl ChatComposer {
transcript_selection_active: self.transcript_selection_active,
transcript_scroll_position: self.transcript_scroll_position,
transcript_copy_selection_key: self.transcript_copy_selection_key,
+ transcript_copy_feedback: self.transcript_copy_feedback,
}
}
@@ -1577,11 +1581,23 @@ impl ChatComposer {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: KeyBinding,
- ) {
+ copy_feedback: Option,
+ ) -> bool {
+ if self.transcript_scrolled == scrolled
+ && self.transcript_selection_active == selection_active
+ && self.transcript_scroll_position == scroll_position
+ && self.transcript_copy_selection_key == copy_selection_key
+ && self.transcript_copy_feedback == copy_feedback
+ {
+ return false;
+ }
+
self.transcript_scrolled = scrolled;
self.transcript_selection_active = selection_active;
self.transcript_scroll_position = scroll_position;
self.transcript_copy_selection_key = copy_selection_key;
+ self.transcript_copy_feedback = copy_feedback;
+ true
}
fn sync_popups(&mut self) {
diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs
index 57bffd5639..f4ead67be6 100644
--- a/codex-rs/tui2/src/bottom_pane/footer.rs
+++ b/codex-rs/tui2/src/bottom_pane/footer.rs
@@ -4,6 +4,7 @@ use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::line_utils::prefix_lines;
use crate::status::format_tokens_compact;
+use crate::transcript_copy_action::TranscriptCopyFeedback;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
@@ -26,6 +27,7 @@ pub(crate) struct FooterProps {
pub(crate) transcript_selection_active: bool,
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
pub(crate) transcript_copy_selection_key: KeyBinding,
+ pub(crate) transcript_copy_feedback: Option,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -80,11 +82,26 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
}
fn footer_lines(props: FooterProps) -> Vec> {
+ fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) {
+ let Some(line) = lines.first_mut() else {
+ return;
+ };
+ let Some(feedback) = feedback else {
+ return;
+ };
+
+ line.push_span(" · ".dim());
+ match feedback {
+ TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()),
+ TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()),
+ }
+ }
+
// Show the context indicator on the left, appended after the primary hint
// (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when
// the shortcut hint is hidden). Hide it only for the multi-line
// ShortcutOverlay.
- match props.mode {
+ let mut lines = match props.mode {
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})],
@@ -139,7 +156,9 @@ fn footer_lines(props: FooterProps) -> Vec> {
props.context_window_percent,
props.context_window_used_tokens,
)],
- }
+ };
+ apply_copy_feedback(&mut lines, props.transcript_copy_feedback);
+ lines
}
#[derive(Clone, Copy, Debug)]
@@ -469,6 +488,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -485,6 +505,7 @@ mod tests {
transcript_selection_active: true,
transcript_scroll_position: Some((3, 42)),
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -501,6 +522,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -517,6 +539,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -533,6 +556,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -549,6 +573,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -565,6 +590,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -581,6 +607,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
},
);
@@ -597,6 +624,24 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: None,
+ },
+ );
+
+ snapshot_footer(
+ "footer_copy_feedback_copied",
+ FooterProps {
+ mode: FooterMode::ShortcutSummary,
+ esc_backtrack_hint: false,
+ use_shift_enter_hint: false,
+ is_task_running: false,
+ context_window_percent: None,
+ context_window_used_tokens: None,
+ transcript_scrolled: false,
+ transcript_selection_active: false,
+ transcript_scroll_position: None,
+ transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
+ transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied),
},
);
}
diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs
index 961254def8..2ebd0715e7 100644
--- a/codex-rs/tui2/src/bottom_pane/mod.rs
+++ b/codex-rs/tui2/src/bottom_pane/mod.rs
@@ -388,14 +388,18 @@ impl BottomPane {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
+ copy_feedback: Option,
) {
- self.composer.set_transcript_ui_state(
+ let updated = self.composer.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
+ copy_feedback,
);
- self.request_redraw();
+ if updated {
+ self.request_redraw();
+ }
}
/// Show a generic list selection view with the provided items.
diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap
new file mode 100644
index 0000000000..77a9306adc
--- /dev/null
+++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap
@@ -0,0 +1,6 @@
+---
+source: tui2/src/bottom_pane/footer.rs
+assertion_line: 473
+expression: terminal.backend()
+---
+" 100% context left · ? for shortcuts · Copied "
diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs
index 723390ef8b..d92cf60214 100644
--- a/codex-rs/tui2/src/chatwidget.rs
+++ b/codex-rs/tui2/src/chatwidget.rs
@@ -13,7 +13,6 @@ use codex_core::config::types::Notifications;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::models_manager::manager::ModelsManager;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -267,7 +266,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) models_manager: Arc,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
- pub(crate) model_family: ModelFamily,
+ pub(crate) model: String,
}
#[derive(Default)]
@@ -284,7 +283,7 @@ pub(crate) struct ChatWidget {
bottom_pane: BottomPane,
active_cell: Option>,
config: Config,
- model_family: ModelFamily,
+ model: String,
auth_manager: Arc,
models_manager: Arc,
session_header: SessionHeader,
@@ -573,12 +572,10 @@ impl ChatWidget {
}
fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option {
- info.model_context_window
- .or(self.model_family.context_window)
- .map(|window| {
- info.last_token_usage
- .percent_of_context_window_remaining(window)
- })
+ info.model_context_window.map(|window| {
+ info.last_token_usage
+ .percent_of_context_window_remaining(window)
+ })
}
fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option {
@@ -646,7 +643,7 @@ impl ChatWidget {
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
- && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG
+ && self.model != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
@@ -680,9 +677,6 @@ impl ChatWidget {
self.stream_controller = None;
self.maybe_show_pending_rate_limit_prompt();
}
- pub(crate) fn get_model_family(&self) -> ModelFamily {
- self.model_family.clone()
- }
fn on_error(&mut self, message: String) {
self.finalize_turn();
@@ -959,11 +953,11 @@ impl ChatWidget {
}
}
- fn on_stream_error(&mut self, message: String) {
+ fn on_stream_error(&mut self, message: String, additional_details: Option) {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status_header.clone());
}
- self.set_status_header(message);
+ self.set_status(message, additional_details);
}
/// Periodic tick to commit at most one queued line to history with a small delay,
@@ -974,6 +968,7 @@ impl ChatWidget {
if let Some(cell) = cell {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
+ self.request_redraw();
}
if is_idle {
self.app_event_tx.send(AppEvent::StopCommitAnimation);
@@ -1015,6 +1010,7 @@ impl ChatWidget {
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
+ let mut needs_redraw = self.active_cell.is_some();
self.flush_active_cell();
if self.stream_controller.is_none() {
@@ -1025,6 +1021,7 @@ impl ChatWidget {
.map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds);
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
self.needs_final_message_separator = false;
+ needs_redraw = true;
}
self.stream_controller = Some(StreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
@@ -1035,7 +1032,9 @@ impl ChatWidget {
{
self.app_event_tx.send(AppEvent::StartCommitAnimation);
}
- self.request_redraw();
+ if needs_redraw {
+ self.request_redraw();
+ }
}
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
@@ -1280,11 +1279,10 @@ impl ChatWidget {
models_manager,
feedback,
is_first_run,
- model_family,
+ model,
} = common;
- let model_slug = model_family.get_model_slug().to_string();
let mut config = config;
- config.model = Some(model_slug.clone());
+ config.model = Some(model.clone());
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
@@ -1305,10 +1303,10 @@ impl ChatWidget {
}),
active_cell: None,
config,
- model_family,
+ model: model.clone(),
auth_manager,
models_manager,
- session_header: SessionHeader::new(model_slug),
+ session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1364,10 +1362,9 @@ impl ChatWidget {
auth_manager,
models_manager,
feedback,
- model_family,
+ model,
..
} = common;
- let model_slug = model_family.get_model_slug().to_string();
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
@@ -1390,10 +1387,10 @@ impl ChatWidget {
}),
active_cell: None,
config,
- model_family,
+ model: model.clone(),
auth_manager,
models_manager,
- session_header: SessionHeader::new(model_slug),
+ session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1905,9 +1902,11 @@ impl ChatWidget {
}
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
- EventMsg::StreamError(StreamErrorEvent { message, .. }) => {
- self.on_stream_error(message)
- }
+ EventMsg::StreamError(StreamErrorEvent {
+ message,
+ additional_details,
+ ..
+ }) => self.on_stream_error(message, additional_details),
EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);
@@ -2050,22 +2049,20 @@ impl ChatWidget {
pub(crate) fn add_status_output(&mut self) {
let default_usage = TokenUsage::default();
- let (total_usage, context_usage) = if let Some(ti) = &self.token_info {
- (&ti.total_token_usage, Some(&ti.last_token_usage))
- } else {
- (&default_usage, Some(&default_usage))
- };
+ let token_info = self.token_info.as_ref();
+ let total_usage = token_info
+ .map(|ti| &ti.total_token_usage)
+ .unwrap_or(&default_usage);
self.add_to_history(crate::status::new_status_output(
&self.config,
self.auth_manager.as_ref(),
- &self.model_family,
+ token_info,
total_usage,
- context_usage,
&self.conversation_id,
self.rate_limit_snapshot.as_ref(),
self.plan_type,
Local::now(),
- self.model_family.get_model_slug(),
+ &self.model,
));
}
fn stop_rate_limit_poller(&mut self) {
@@ -2208,7 +2205,6 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
- let current_model = self.model_family.get_model_slug().to_string();
let presets: Vec =
// todo(aibrahim): make this async function
match self.models_manager.try_list_models(&self.config) {
@@ -2225,9 +2221,9 @@ impl ChatWidget {
let current_label = presets
.iter()
- .find(|preset| preset.model == current_model)
+ .find(|preset| preset.model == self.model)
.map(|preset| preset.display_name.to_string())
- .unwrap_or_else(|| current_model.clone());
+ .unwrap_or_else(|| self.model.clone());
let (mut auto_presets, other_presets): (Vec, Vec) = presets
.into_iter()
@@ -2253,7 +2249,7 @@ impl ChatWidget {
SelectionItem {
name: preset.display_name.clone(),
description,
- is_current: model == current_model,
+ is_current: model == self.model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
@@ -2316,12 +2312,11 @@ impl ChatWidget {
return;
}
- let current_model = self.model_family.get_model_slug().to_string();
let mut items: Vec = Vec::new();
for preset in presets.into_iter() {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
- let is_current = preset.model == current_model;
+ let is_current = preset.model == self.model;
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let preset_for_action = preset.clone();
let actions: Vec = vec![Box::new(move |tx| {
@@ -2447,7 +2442,7 @@ impl ChatWidget {
.or(Some(default_effort));
let model_slug = preset.model.to_string();
- let is_current_model = self.model_family.get_model_slug() == preset.model;
+ let is_current_model = self.model == preset.model;
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
@@ -3006,9 +3001,9 @@ impl ChatWidget {
}
/// Set the model in the widget's config copy.
- pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) {
+ pub(crate) fn set_model(&mut self, model: &str) {
self.session_header.set_model(model);
- self.model_family = model_family;
+ self.model = model.to_string();
}
pub(crate) fn add_info_message(&mut self, message: String, hint: Option) {
@@ -3101,12 +3096,14 @@ impl ChatWidget {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
+ copy_feedback: Option,
) {
self.bottom_pane.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
+ copy_feedback,
);
}
diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs
index a71be3a635..8b216812df 100644
--- a/codex-rs/tui2/src/chatwidget/tests.rs
+++ b/codex-rs/tui2/src/chatwidget/tests.rs
@@ -311,7 +311,6 @@ async fn helpers_are_available_and_do_not_panic() {
let tx = AppEventSender::new(tx_raw);
let cfg = test_config().await;
let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref());
- let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg);
let conversation_manager = Arc::new(ConversationManager::with_models_provider(
CodexAuth::from_api_key("test"),
cfg.model_provider.clone(),
@@ -328,7 +327,7 @@ async fn helpers_are_available_and_do_not_panic() {
models_manager: conversation_manager.get_models_manager(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
- model_family,
+ model: resolved_model,
};
let mut w = ChatWidget::new(init, conversation_manager);
// Basic construction sanity.
@@ -369,11 +368,11 @@ async fn make_chatwidget_manual(
codex_op_tx: op_tx,
bottom_pane: bottom,
active_cell: None,
- config: cfg.clone(),
- model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg),
+ config: cfg,
+ model: resolved_model.clone(),
auth_manager: auth_manager.clone(),
models_manager: Arc::new(ModelsManager::new(auth_manager)),
- session_header: SessionHeader::new(resolved_model.clone()),
+ session_header: SessionHeader::new(resolved_model),
initial_user_message: None,
token_info: None,
rate_limit_snapshot: None,
@@ -2875,11 +2874,13 @@ async fn stream_error_updates_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
let msg = "Reconnecting... 2/5";
+ let details = "Idle timeout waiting for SSE";
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: msg.to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
+ additional_details: Some(details.to_string()),
}),
});
@@ -2893,6 +2894,7 @@ async fn stream_error_updates_status_indicator() {
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
+ assert_eq!(status.details(), Some(details));
}
#[tokio::test]
@@ -2929,6 +2931,7 @@ async fn stream_recovery_restores_previous_status_header() {
msg: EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
+ additional_details: None,
}),
});
drain_insert_history(&mut rx);
diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs
index 4583a5d84a..8cd3fde133 100644
--- a/codex-rs/tui2/src/lib.rs
+++ b/codex-rs/tui2/src/lib.rs
@@ -29,7 +29,6 @@ use std::path::PathBuf;
use tracing::error;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
-use tracing_subscriber::filter::Targets;
use tracing_subscriber::prelude::*;
mod additional_dirs;
@@ -78,10 +77,14 @@ mod terminal_palette;
mod text_formatting;
mod tooltips;
mod transcript_copy;
+mod transcript_copy_action;
mod transcript_copy_ui;
mod transcript_multi_click;
mod transcript_render;
+mod transcript_scrollbar;
+mod transcript_scrollbar_ui;
mod transcript_selection;
+mod transcript_view_cache;
mod tui;
mod ui_consts;
pub mod update_action;
@@ -292,13 +295,8 @@ pub async fn run_main(
.with_filter(env_filter());
let feedback = codex_feedback::CodexFeedback::new();
- let targets = Targets::new().with_default(tracing::Level::TRACE);
-
- let feedback_layer = tracing_subscriber::fmt::layer()
- .with_writer(feedback.make_writer())
- .with_ansi(false)
- .with_target(false)
- .with_filter(targets);
+ let feedback_layer = feedback.logger_layer();
+ let feedback_metadata_layer = feedback.metadata_layer();
if cli.oss && model_provider_override.is_some() {
// We're in the oss section, so provider_id should be Some
@@ -333,6 +331,7 @@ pub async fn run_main(
let _ = tracing_subscriber::registry()
.with(file_layer)
.with(feedback_layer)
+ .with(feedback_metadata_layer)
.with(otel_tracing_layer)
.with(otel_logger_layer)
.try_init();
diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs
index 429134362a..3e7a626e40 100644
--- a/codex-rs/tui2/src/status/card.rs
+++ b/codex-rs/tui2/src/status/card.rs
@@ -7,10 +7,10 @@ use chrono::DateTime;
use chrono::Local;
use codex_common::create_config_summary_entries;
use codex_core::config::Config;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
+use codex_core::protocol::TokenUsageInfo;
use codex_protocol::ConversationId;
use codex_protocol::account::PlanType;
use ratatui::prelude::*;
@@ -72,9 +72,8 @@ struct StatusHistoryCell {
pub(crate) fn new_status_output(
config: &Config,
auth_manager: &AuthManager,
- model_family: &ModelFamily,
+ token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
- context_usage: Option<&TokenUsage>,
session_id: &Option,
rate_limits: Option<&RateLimitSnapshotDisplay>,
plan_type: Option,
@@ -85,9 +84,8 @@ pub(crate) fn new_status_output(
let card = StatusHistoryCell::new(
config,
auth_manager,
- model_family,
+ token_info,
total_usage,
- context_usage,
session_id,
rate_limits,
plan_type,
@@ -103,9 +101,8 @@ impl StatusHistoryCell {
fn new(
config: &Config,
auth_manager: &AuthManager,
- model_family: &ModelFamily,
+ token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
- context_usage: Option<&TokenUsage>,
session_id: &Option,
rate_limits: Option<&RateLimitSnapshotDisplay>,
plan_type: Option,
@@ -134,12 +131,15 @@ impl StatusHistoryCell {
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(auth_manager, plan_type);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
- let context_window = model_family.context_window.and_then(|window| {
- context_usage.map(|usage| StatusContextWindowData {
- percent_remaining: usage.percent_of_context_window_remaining(window),
- tokens_in_context: usage.tokens_in_context_window(),
- window,
- })
+ let default_usage = TokenUsage::default();
+ let (context_usage, context_window) = match token_info {
+ Some(info) => (&info.last_token_usage, info.model_context_window),
+ None => (&default_usage, config.model_context_window),
+ };
+ let context_window = context_window.map(|window| StatusContextWindowData {
+ percent_remaining: context_usage.percent_of_context_window_remaining(window),
+ tokens_in_context: context_usage.tokens_in_context_window(),
+ window,
});
let token_usage = StatusTokenUsageData {
diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs
index 317a3d3270..7eb18dd48b 100644
--- a/codex-rs/tui2/src/status/tests.rs
+++ b/codex-rs/tui2/src/status/tests.rs
@@ -8,12 +8,12 @@ use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::models_manager::manager::ModelsManager;
-use codex_core::models_manager::model_family::ModelFamily;
use codex_core::protocol::CreditsSnapshot;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
+use codex_core::protocol::TokenUsageInfo;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ReasoningEffort;
use insta::assert_snapshot;
@@ -37,8 +37,15 @@ fn test_auth_manager(config: &Config) -> AuthManager {
)
}
-fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily {
- ModelsManager::construct_model_family_offline(model_slug, config)
+fn token_info_for(model_slug: &str, config: &Config, usage: &TokenUsage) -> TokenUsageInfo {
+ let context_window = ModelsManager::construct_model_family_offline(model_slug, config)
+ .context_window
+ .or(config.model_context_window);
+ TokenUsageInfo {
+ total_token_usage: usage.clone(),
+ last_token_usage: usage.clone(),
+ model_context_window: context_window,
+ }
}
fn render_lines(lines: &[Line<'static>]) -> Vec {
@@ -132,14 +139,13 @@ async fn status_snapshot_includes_reasoning_details() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -155,7 +161,6 @@ async fn status_snapshot_includes_reasoning_details() {
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
-
#[tokio::test]
async fn status_snapshot_includes_monthly_limit() {
let temp_home = TempDir::new().expect("temp home");
@@ -190,13 +195,12 @@ async fn status_snapshot_includes_monthly_limit() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -212,7 +216,6 @@ async fn status_snapshot_includes_monthly_limit() {
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
-
#[tokio::test]
async fn status_snapshot_shows_unlimited_credits() {
let temp_home = TempDir::new().expect("temp home");
@@ -235,13 +238,12 @@ async fn status_snapshot_shows_unlimited_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -279,13 +281,12 @@ async fn status_snapshot_shows_positive_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -323,13 +324,12 @@ async fn status_snapshot_hides_zero_credits() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -365,13 +365,12 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -407,13 +406,12 @@ async fn status_card_token_usage_excludes_cached_tokens() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
None,
None,
@@ -464,13 +462,12 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -510,13 +507,12 @@ async fn status_snapshot_shows_missing_limits_message() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
None,
None,
@@ -532,7 +528,6 @@ async fn status_snapshot_shows_missing_limits_message() {
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
-
#[tokio::test]
async fn status_snapshot_includes_credits_and_limits() {
let temp_home = TempDir::new().expect("temp home");
@@ -574,13 +569,12 @@ async fn status_snapshot_includes_credits_and_limits() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -626,13 +620,12 @@ async fn status_snapshot_shows_empty_limits_message() {
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -687,13 +680,12 @@ async fn status_snapshot_shows_stale_limits_message() {
let now = captured_at + ChronoDuration::minutes(20);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -752,13 +744,12 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
let now = captured_at + ChronoDuration::minutes(20);
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&usage,
- Some(&usage),
&None,
Some(&rate_display),
None,
@@ -803,13 +794,16 @@ async fn status_context_window_uses_last_usage() {
.expect("timestamp");
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
- let model_family = test_model_family(&model_slug, &config);
+ let token_info = TokenUsageInfo {
+ total_token_usage: total_usage.clone(),
+ last_token_usage: last_usage,
+ model_context_window: config.model_context_window,
+ };
let composite = new_status_output(
&config,
&auth_manager,
- &model_family,
+ Some(&token_info),
&total_usage,
- Some(&last_usage),
&None,
None,
None,
diff --git a/codex-rs/tui2/src/terminal_palette.rs b/codex-rs/tui2/src/terminal_palette.rs
index 5c6f32cd9e..941cf78a2b 100644
--- a/codex-rs/tui2/src/terminal_palette.rs
+++ b/codex-rs/tui2/src/terminal_palette.rs
@@ -1,5 +1,13 @@
use crate::color::perceptual_distance;
use ratatui::style::Color;
+use std::sync::atomic::AtomicU64;
+use std::sync::atomic::Ordering;
+
+static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0);
+
+fn bump_palette_version() {
+ DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
+}
/// Returns the closest color to the target color that the terminal can display.
pub fn best_color(target: (u8, u8, u8)) -> Color {
@@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color {
pub fn requery_default_colors() {
imp::requery_default_colors();
+ bump_palette_version();
}
#[derive(Clone, Copy)]
@@ -47,6 +56,10 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.bg)
}
+pub fn palette_version() -> u64 {
+ DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
+}
+
#[cfg(all(unix, not(test)))]
mod imp {
use super::DefaultColors;
diff --git a/codex-rs/tui2/src/transcript_copy_action.rs b/codex-rs/tui2/src/transcript_copy_action.rs
new file mode 100644
index 0000000000..49a894d335
--- /dev/null
+++ b/codex-rs/tui2/src/transcript_copy_action.rs
@@ -0,0 +1,228 @@
+//! Performs "copy selection" and manages transient UI feedback.
+//!
+//! `transcript_copy` is intentionally pure: it reconstructs clipboard text from a
+//! [`TranscriptSelection`], preserving wrapping, indentation, and Markdown markers.
+//!
+//! This module is the side-effecting layer on top of that pure logic:
+//! - writes the reconstructed text to the system clipboard
+//! - stores short-lived state so the footer can show `"Copied"` / `"Copy failed"`
+//! - schedules redraws so feedback appears promptly and then clears itself
+//!
+//! Keeping these responsibilities separate reduces cognitive load:
+//! - `transcript_copy` answers *what text should be copied?*
+//! - `transcript_copy_action` answers *do the copy and tell the user it happened*
+
+use std::sync::Arc;
+use std::time::Duration;
+use std::time::Instant;
+
+use crate::history_cell::HistoryCell;
+use crate::transcript_scrollbar::split_transcript_area;
+use crate::transcript_selection::TranscriptSelection;
+use crate::tui;
+use ratatui::layout::Rect;
+
+/// User-visible feedback shown briefly after a copy attempt.
+///
+/// The footer renders this value when present, and it expires automatically.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum TranscriptCopyFeedback {
+ /// Copy succeeded and the clipboard was updated.
+ Copied,
+ /// Copy failed (typically due to OS clipboard integration issues).
+ Failed,
+}
+
+/// The outcome of attempting to copy the current selection.
+///
+/// This is a compact signal for UI code:
+/// - `NoSelection` means the action is a no-op (nothing to dismiss).
+/// - `Copied`/`Failed` mean the action was triggered and the selection should be dismissed.
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub(crate) enum CopySelectionOutcome {
+ /// No active selection exists (or the terminal is too small to compute one).
+ NoSelection,
+ /// Clipboard write succeeded.
+ Copied,
+ /// Clipboard write failed.
+ Failed,
+}
+
+const TRANSCRIPT_COPY_FEEDBACK_DURATION: Duration = Duration::from_millis(1500);
+
+#[derive(Debug, Clone, Copy)]
+struct TranscriptCopyFeedbackState {
+ kind: TranscriptCopyFeedback,
+ expires_at: Instant,
+}
+
+/// Performs the copy action and tracks transient footer feedback.
+///
+/// `App` owns one instance and calls [`Self::copy_and_handle`] when the user triggers "copy
+/// selection" (either via the on-screen copy pill or the keyboard shortcut).
+#[derive(Debug, Default)]
+pub(crate) struct TranscriptCopyAction {
+ feedback: Option,
+}
+
+impl TranscriptCopyAction {
+ /// Attempt to copy the current selection and record feedback.
+ ///
+ /// Returns `true` when a copy attempt was made (success or failure). Callers should treat that
+ /// as a signal to dismiss the selection highlight.
+ pub(crate) fn copy_and_handle(
+ &mut self,
+ tui: &mut tui::Tui,
+ chat_height: u16,
+ transcript_cells: &[Arc],
+ transcript_selection: TranscriptSelection,
+ ) -> bool {
+ let outcome =
+ copy_transcript_selection(tui, chat_height, transcript_cells, transcript_selection);
+ self.handle_copy_outcome(tui, outcome)
+ }
+
+ /// Return footer feedback to render for the current frame, if any.
+ ///
+ /// This is called from `App`'s render loop. It clears expired feedback lazily so callers do
+ /// not need separate timer plumbing.
+ pub(crate) fn footer_feedback(&mut self) -> Option {
+ let state = self.feedback?;
+
+ if Instant::now() >= state.expires_at {
+ self.feedback = None;
+ return None;
+ }
+
+ Some(state.kind)
+ }
+
+ /// Record the outcome of a copy attempt and schedule redraws.
+ ///
+ /// Returns `true` when a copy attempt happened (success or failure). This is the signal to
+ /// dismiss the selection highlight.
+ pub(crate) fn handle_copy_outcome(
+ &mut self,
+ tui: &mut tui::Tui,
+ outcome: CopySelectionOutcome,
+ ) -> bool {
+ match outcome {
+ CopySelectionOutcome::NoSelection => false,
+ CopySelectionOutcome::Copied => {
+ self.set_feedback(tui, TranscriptCopyFeedback::Copied);
+ true
+ }
+ CopySelectionOutcome::Failed => {
+ self.set_feedback(tui, TranscriptCopyFeedback::Failed);
+ true
+ }
+ }
+ }
+
+ /// Store feedback state and schedule a redraw for its appearance + expiration.
+ fn set_feedback(&mut self, tui: &mut tui::Tui, kind: TranscriptCopyFeedback) {
+ let expires_at = Instant::now()
+ .checked_add(TRANSCRIPT_COPY_FEEDBACK_DURATION)
+ .unwrap_or_else(Instant::now);
+ self.feedback = Some(TranscriptCopyFeedbackState { kind, expires_at });
+
+ tui.frame_requester().schedule_frame();
+ tui.frame_requester()
+ .schedule_frame_in(TRANSCRIPT_COPY_FEEDBACK_DURATION);
+ }
+}
+
+/// Copy the current transcript selection to the system clipboard.
+///
+/// This function ties together layout validation, selection-to-text reconstruction via
+/// `transcript_copy`, and the actual clipboard write.
+pub(crate) fn copy_transcript_selection(
+ tui: &tui::Tui,
+ chat_height: u16,
+ transcript_cells: &[Arc],
+ transcript_selection: TranscriptSelection,
+) -> CopySelectionOutcome {
+ // This function is intentionally "dumb plumbing":
+ // - validate layout prerequisites
+ // - reconstruct clipboard text (`transcript_copy`)
+ // - write to clipboard
+ //
+ // UI state management (feedback + redraw scheduling) lives in `TranscriptCopyAction`.
+ let size = tui.terminal.last_known_screen_size;
+ let width = size.width;
+ let height = size.height;
+ if width == 0 || height == 0 {
+ return CopySelectionOutcome::NoSelection;
+ }
+
+ if chat_height >= height {
+ return CopySelectionOutcome::NoSelection;
+ }
+
+ let transcript_height = height.saturating_sub(chat_height);
+ if transcript_height == 0 {
+ return CopySelectionOutcome::NoSelection;
+ }
+
+ let transcript_full_area = Rect {
+ x: 0,
+ y: 0,
+ width,
+ height: transcript_height,
+ };
+ let (transcript_area, _) = split_transcript_area(transcript_full_area);
+
+ let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
+ transcript_cells,
+ transcript_selection,
+ transcript_area.width,
+ ) else {
+ return CopySelectionOutcome::NoSelection;
+ };
+
+ if let Err(err) = crate::clipboard_copy::copy_text(text) {
+ tracing::error!(error = %err, "failed to copy selection to clipboard");
+ return CopySelectionOutcome::Failed;
+ }
+
+ CopySelectionOutcome::Copied
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+
+ #[test]
+ fn footer_feedback_returns_value_before_expiration() {
+ let mut action = TranscriptCopyAction {
+ feedback: Some(TranscriptCopyFeedbackState {
+ kind: TranscriptCopyFeedback::Copied,
+ expires_at: Instant::now() + Duration::from_secs(10),
+ }),
+ };
+
+ assert_eq!(
+ action.footer_feedback(),
+ Some(TranscriptCopyFeedback::Copied)
+ );
+ assert_eq!(
+ action.footer_feedback(),
+ Some(TranscriptCopyFeedback::Copied)
+ );
+ }
+
+ #[test]
+ fn footer_feedback_clears_after_expiration() {
+ let mut action = TranscriptCopyAction {
+ feedback: Some(TranscriptCopyFeedbackState {
+ kind: TranscriptCopyFeedback::Copied,
+ expires_at: Instant::now() - Duration::from_secs(1),
+ }),
+ };
+
+ assert_eq!(action.footer_feedback(), None);
+ assert!(action.feedback.is_none());
+ assert_eq!(action.footer_feedback(), None);
+ }
+}
diff --git a/codex-rs/tui2/src/transcript_copy_ui.rs b/codex-rs/tui2/src/transcript_copy_ui.rs
index 6c852c2341..6ec76a1de9 100644
--- a/codex-rs/tui2/src/transcript_copy_ui.rs
+++ b/codex-rs/tui2/src/transcript_copy_ui.rs
@@ -249,8 +249,16 @@ impl TranscriptCopyUi {
let Some((y, to_x)) = last_visible_segment else {
return;
};
- // Place the pill on the row below the last visible selection segment.
- let Some(y) = y.checked_add(1).filter(|y| *y < area.bottom()) else {
+ // Prefer placing the pill on the row below the last visible selection segment. If the
+ // selection ends on the last visible row, fall back to placing it above (or, if the view
+ // is only one row tall, on the same row).
+ let Some(y) = y
+ .checked_add(1)
+ .filter(|y| *y < area.bottom())
+ .or_else(|| y.checked_sub(1).filter(|y| *y >= area.y))
+ .or(Some(y))
+ .filter(|y| *y < area.bottom())
+ else {
return;
};
@@ -275,8 +283,8 @@ impl TranscriptCopyUi {
};
let pill_area = Rect::new(x, y, pill_width, 1);
- let base_style = Style::new().bg(Color::DarkGray);
- let icon_style = base_style.fg(Color::Cyan);
+ let base_style = Style::new().bg(Color::DarkGray).fg(Color::White);
+ let icon_style = base_style.add_modifier(Modifier::BOLD).fg(Color::LightCyan);
let bold_style = base_style.add_modifier(Modifier::BOLD);
let mut spans: Vec> = vec![
@@ -329,4 +337,26 @@ mod tests {
assert!(!rendered.contains("ctrl + shift + c"));
assert!(ui.affordance_rect.is_some());
}
+
+ #[test]
+ fn pill_renders_when_selection_on_last_row() {
+ let area = Rect::new(0, 0, 60, 3);
+ let mut buf = Buffer::empty(area);
+ for y in 0..area.height {
+ for x in 2..area.width.saturating_sub(1) {
+ buf[(x, y)].set_symbol("X");
+ }
+ }
+
+ let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlShiftC);
+ ui.render_copy_pill(area, &mut buf, (2, 2), (2, 6), 0, 3);
+
+ let rendered = buf_to_string(&buf, area);
+ assert!(rendered.contains("copy"));
+ assert!(rendered.contains("ctrl + shift + c"));
+
+ let rect = ui.affordance_rect.expect("expected pill to render");
+ assert_eq!(rect.y, 1);
+ assert!(ui.hit_test(rect.x, rect.y));
+ }
}
diff --git a/codex-rs/tui2/src/transcript_render.rs b/codex-rs/tui2/src/transcript_render.rs
index ceee03d897..729ef54bc3 100644
--- a/codex-rs/tui2/src/transcript_render.rs
+++ b/codex-rs/tui2/src/transcript_render.rs
@@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines(
cells: &[Arc],
width: u16,
) -> TranscriptLines {
- use crate::render::line_utils::line_to_static;
- use ratatui::style::Color;
-
if width == 0 {
return TranscriptLines {
lines: Vec::new(),
@@ -124,110 +121,140 @@ pub(crate) fn build_wrapped_transcript_lines(
};
}
+ let mut transcript = TranscriptLines {
+ lines: Vec::new(),
+ meta: Vec::new(),
+ joiner_before: Vec::new(),
+ };
+ let mut has_emitted_lines = false;
let base_opts: crate::wrapping::RtOptions<'_> =
crate::wrapping::RtOptions::new(width.max(1) as usize);
- let mut lines: Vec> = Vec::new();
- let mut meta: Vec = Vec::new();
- let mut joiner_before: Vec> = Vec::new();
- let mut has_emitted_lines = false;
-
for (cell_index, cell) in cells.iter().enumerate() {
- // Start from each cell's transcript view (prefixes/indents already applied), then apply
- // viewport wrapping to prose while keeping preformatted content intact.
- let rendered = cell.transcript_lines_with_joiners(width);
- if rendered.lines.is_empty() {
+ append_wrapped_transcript_cell(
+ &mut transcript,
+ &mut has_emitted_lines,
+ cell_index,
+ cell,
+ width,
+ &base_opts,
+ );
+ }
+
+ transcript
+}
+
+/// Append a single history cell to an existing wrapped transcript.
+///
+/// This is the incremental building block used by transcript caching: it applies the same
+/// flattening and viewport-wrapping rules as [`build_wrapped_transcript_lines`], but for one cell
+/// at a time.
+///
+/// `has_emitted_lines` tracks whether the output already contains any non-spacer lines and is used
+/// to decide when to insert an inter-cell spacer row.
+pub(crate) fn append_wrapped_transcript_cell(
+ out: &mut TranscriptLines,
+ has_emitted_lines: &mut bool,
+ cell_index: usize,
+ cell: &Arc,
+ width: u16,
+ base_opts: &crate::wrapping::RtOptions<'_>,
+) {
+ use crate::render::line_utils::line_to_static;
+ use ratatui::style::Color;
+
+ if width == 0 {
+ return;
+ }
+
+ // Start from each cell's transcript view (prefixes/indents already applied), then apply
+ // viewport wrapping to prose while keeping preformatted content intact.
+ let rendered = cell.transcript_lines_with_joiners(width);
+ if rendered.lines.is_empty() {
+ return;
+ }
+
+ if !cell.is_stream_continuation() {
+ if *has_emitted_lines {
+ out.lines.push(Line::from(""));
+ out.meta.push(TranscriptLineMeta::Spacer);
+ out.joiner_before.push(None);
+ } else {
+ *has_emitted_lines = true;
+ }
+ }
+
+ // `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
+ // viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
+ // lines), since a single input line may wrap into multiple visual lines.
+ let mut visual_line_in_cell: usize = 0;
+ let mut first = true;
+ for (base_idx, base_line) in rendered.lines.iter().enumerate() {
+ // Preserve code blocks (and other preformatted text) by not applying
+ // viewport wrapping, so indentation remains meaningful for copy/paste.
+ if base_line.style.fg == Some(Color::Cyan) {
+ out.lines.push(base_line.clone());
+ out.meta.push(TranscriptLineMeta::CellLine {
+ cell_index,
+ line_in_cell: visual_line_in_cell,
+ });
+ visual_line_in_cell = visual_line_in_cell.saturating_add(1);
+ // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
+ // (which is typically `None`).
+ out.joiner_before.push(
+ rendered
+ .joiner_before
+ .get(base_idx)
+ .cloned()
+ .unwrap_or(None),
+ );
+ first = false;
continue;
}
- if !cell.is_stream_continuation() {
- if has_emitted_lines {
- lines.push(Line::from(""));
- meta.push(TranscriptLineMeta::Spacer);
- joiner_before.push(None);
- } else {
- has_emitted_lines = true;
- }
- }
+ let opts = if first {
+ base_opts.clone()
+ } else {
+ // For subsequent input lines within a cell, treat the "initial" indent as the cell's
+ // subsequent indent (matches textarea wrapping expectations).
+ base_opts
+ .clone()
+ .initial_indent(base_opts.subsequent_indent.clone())
+ };
+ // `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
+ // continuation segment, the exact joiner substring that should be inserted instead of a
+ // newline when copying as a logical line.
+ let (wrapped, wrapped_joiners) =
+ crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
- // `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
- // viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
- // lines), since a single input line may wrap into multiple visual lines.
- let mut visual_line_in_cell: usize = 0;
- let mut first = true;
- for (base_idx, base_line) in rendered.lines.iter().enumerate() {
- // Preserve code blocks (and other preformatted text) by not applying
- // viewport wrapping, so indentation remains meaningful for copy/paste.
- if base_line.style.fg == Some(Color::Cyan) {
- lines.push(base_line.clone());
- meta.push(TranscriptLineMeta::CellLine {
- cell_index,
- line_in_cell: visual_line_in_cell,
- });
- visual_line_in_cell = visual_line_in_cell.saturating_add(1);
- // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
- // (which is typically `None`).
- joiner_before.push(
+ for (seg_idx, (wrapped_line, seg_joiner)) in
+ wrapped.into_iter().zip(wrapped_joiners).enumerate()
+ {
+ out.lines.push(line_to_static(&wrapped_line));
+ out.meta.push(TranscriptLineMeta::CellLine {
+ cell_index,
+ line_in_cell: visual_line_in_cell,
+ });
+ visual_line_in_cell = visual_line_in_cell.saturating_add(1);
+
+ if seg_idx == 0 {
+ // The first wrapped segment corresponds to the original input line, so we use the
+ // cell-provided joiner (hard break vs soft break *between input lines*).
+ out.joiner_before.push(
rendered
.joiner_before
.get(base_idx)
.cloned()
.unwrap_or(None),
);
- first = false;
- continue;
- }
-
- let opts = if first {
- base_opts.clone()
} else {
- // For subsequent input lines within a cell, treat the "initial" indent as the
- // cell's subsequent indent (matches textarea wrapping expectations).
- base_opts
- .clone()
- .initial_indent(base_opts.subsequent_indent.clone())
- };
- // `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
- // continuation segment, the exact joiner substring that should be inserted instead of a
- // newline when copying as a logical line.
- let (wrapped, wrapped_joiners) =
- crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
-
- for (seg_idx, (wrapped_line, seg_joiner)) in
- wrapped.into_iter().zip(wrapped_joiners).enumerate()
- {
- lines.push(line_to_static(&wrapped_line));
- meta.push(TranscriptLineMeta::CellLine {
- cell_index,
- line_in_cell: visual_line_in_cell,
- });
- visual_line_in_cell = visual_line_in_cell.saturating_add(1);
-
- if seg_idx == 0 {
- // The first wrapped segment corresponds to the original input line, so we use
- // the cell-provided joiner (hard break vs soft break *between input lines*).
- joiner_before.push(
- rendered
- .joiner_before
- .get(base_idx)
- .cloned()
- .unwrap_or(None),
- );
- } else {
- // Subsequent wrapped segments are soft-wrap continuations produced by viewport
- // wrapping, so we use the wrap-derived joiner.
- joiner_before.push(seg_joiner);
- }
+ // Subsequent wrapped segments are soft-wrap continuations produced by viewport
+ // wrapping, so we use the wrap-derived joiner.
+ out.joiner_before.push(seg_joiner);
}
-
- first = false;
}
- }
- TranscriptLines {
- lines,
- meta,
- joiner_before,
+ first = false;
}
}
@@ -396,4 +423,56 @@ mod tests {
]
);
}
+
+ #[test]
+ fn append_wrapped_transcript_cell_matches_full_build() {
+ use ratatui::style::Color;
+ use ratatui::style::Style;
+
+ let cells: Vec> = vec![
+ Arc::new(FakeCell {
+ lines: vec![Line::from("• hello world")],
+ joiner_before: vec![None],
+ is_stream_continuation: false,
+ }),
+ // A preformatted line should not be viewport-wrapped.
+ Arc::new(FakeCell {
+ lines: vec![Line::from("• 1234567890").style(Style::default().fg(Color::Cyan))],
+ joiner_before: vec![None],
+ is_stream_continuation: false,
+ }),
+ // A stream continuation should not get an inter-cell spacer row.
+ Arc::new(FakeCell {
+ lines: vec![Line::from("• wrap me please")],
+ joiner_before: vec![None],
+ is_stream_continuation: true,
+ }),
+ ];
+
+ let width = 7;
+ let full = build_wrapped_transcript_lines(&cells, width);
+
+ let mut out = TranscriptLines {
+ lines: Vec::new(),
+ meta: Vec::new(),
+ joiner_before: Vec::new(),
+ };
+ let mut has_emitted_lines = false;
+ let base_opts: crate::wrapping::RtOptions<'_> =
+ crate::wrapping::RtOptions::new(width.max(1) as usize);
+ for (cell_index, cell) in cells.iter().enumerate() {
+ append_wrapped_transcript_cell(
+ &mut out,
+ &mut has_emitted_lines,
+ cell_index,
+ cell,
+ width,
+ &base_opts,
+ );
+ }
+
+ assert_eq!(out.lines, full.lines);
+ assert_eq!(out.meta, full.meta);
+ assert_eq!(out.joiner_before, full.joiner_before);
+ }
}
diff --git a/codex-rs/tui2/src/transcript_scrollbar.rs b/codex-rs/tui2/src/transcript_scrollbar.rs
new file mode 100644
index 0000000000..d58d758d17
--- /dev/null
+++ b/codex-rs/tui2/src/transcript_scrollbar.rs
@@ -0,0 +1,535 @@
+//! Transcript scrollbar rendering.
+//!
+//! The transcript in `codex-tui2` is rendered as a flattened list of wrapped visual lines. The
+//! viewport is tracked as a top-row offset (`transcript_view_top`) into that flattened list (see
+//! `tui/scrolling.rs` and `tui_viewport_and_history.md`).
+//!
+//! This module adds a scrollbar to that viewport using the `tui-scrollbar` widget, but does so in
+//! a way that keeps the transcript hot path simple and avoids visual layout jank.
+//!
+//! # Layout and invariants
+//!
+//! The transcript area is split into:
+//!
+//! - `content_area`: where transcript text is rendered
+//! - `scrollbar_area`: a 1-column region used to render the scrollbar
+//!
+//! Additionally, we reserve a 1-column *gap* between content and scrollbar. This produces a
+//! slightly more stable/intentional look (the scrollbar reads as an affordance, not part of the
+//! transcript content) and avoids accidental overlap with selection/copy UI.
+//!
+//! Important invariant: **any code that computes transcript wrapping, scrolling, selection, or
+//! copy must use the same width as on-screen transcript rendering**. In practice that means:
+//!
+//! - Use [`split_transcript_area`] and pass `content_area.width` into anything that depends on
+//! transcript width (wrapping, scroll deltas, selection reconstruction for copy, etc.).
+//! - Do not mix `terminal.width` and `content_area.width` for transcript operations; doing so
+//! causes off-by-one/off-by-two behaviors where the selection highlights and copied text do not
+//! match what the user sees.
+//!
+//! `App` follows this rule by deriving `content_area.width` anywhere it needs transcript width.
+//!
+//! # When the scrollbar is shown
+//!
+//! The scrollbar is only drawn when the transcript is *not* pinned to the bottom:
+//!
+//! - `offset < max_offset` → draw scrollbar
+//! - `offset == max_offset` → keep the column reserved but blank
+//!
+//! This keeps the UI clean during normal operation (where the viewport follows streaming output),
+//! while still providing a clear affordance when the user is actively reading scrollback.
+//!
+//! # Styling and theme heuristics
+//!
+//! `tui-scrollbar` 0.2.1 changed defaults (no arrows, space track + dark background). We keep
+//! default glyphs/arrows so the widget controls its own shape, but override track/thumb colors so
+//! it matches `codex-tui2`’s existing "user prompt block" styling:
+//!
+//! - The track background is a small blend toward the terminal foreground so it looks like a
+//! subtle indent against the terminal background.
+//! - The thumb foreground is a stronger blend so it reads as the active element.
+//! - In light themes (terminal background is light), the thumb is intentionally a *darker* shade
+//! than the track so it reads as an inset element.
+//!
+//! We derive these colors from the terminal’s default foreground/background (when available via
+//! `terminal_palette`). When defaults are unknown (tests / unsupported terminals), we fall back to
+//! ANSI colors so the scrollbar remains visible.
+//!
+//! # Pointer interaction (mouse click/drag)
+//!
+//! `tui-scrollbar` includes an interaction helper that translates pointer events into
+//! `ScrollCommand::SetOffset(...)` updates, including "grab offset" handling so dragging keeps the
+//! pointer anchored within the thumb.
+//!
+//! `codex-tui2` uses that helper rather than reimplementing scrollbar hit testing and drag math.
+//! The app owns the actual transcript scroll state (anchors in `tui/scrolling.rs`), so we only use
+//! `tui-scrollbar` to decide *which* offset the user requested. `App` then converts that raw
+//! `offset` back into a stable [`TranscriptScroll`] anchor.
+//!
+//! Note: we use `tui-scrollbar`’s backend-agnostic [`ScrollEvent`] types instead of its optional
+//! `crossterm` adapter, because the workspace uses a patched `crossterm` and we want to avoid
+//! pulling in multiple `crossterm` versions (which would make `MouseEvent` types incompatible).
+//!
+//! Because the scrollbar is visually hidden while pinned-to-bottom, `App` also keeps a tiny
+//! "pointer capture" bool so a drag that reaches the bottom doesn't accidentally turn into a text
+//! selection once the scrollbar disappears.
+//!
+//! # `ratatui` vs `ratatui-core`
+//!
+//! `codex-tui2` uses the `ratatui` crate, while `tui-scrollbar` is built on `ratatui-core`.
+//! Because the buffer and style types are distinct, we render the scrollbar into a small
+//! `ratatui-core` scratch buffer and then copy the resulting glyphs into the main `ratatui`
+//! buffer with `ratatui` styles.
+//!
+//! ## Upgrade note: Ratatui 0.30+
+//!
+//! Ratatui 0.30 split many core types (including `Buffer`, `Rect`, and `Widget`) into the new
+//! `ratatui-core` crate. `codex-tui2` is currently pinned to an older Ratatui, so it still works
+//! with `ratatui::buffer::Buffer` / `ratatui::layout::Rect`, while `tui-scrollbar` is already on
+//! `ratatui-core`.
+//!
+//! That mismatch forces two bits of "glue" that should go away once `codex-tui2` upgrades to
+//! Ratatui 0.30:
+//!
+//! - Rendering: `render_transcript_scrollbar_if_active` currently renders into a `ratatui-core`
+//! scratch buffer and copies glyphs/styles into the `ratatui` buffer. With Ratatui 0.30, the
+//! app’s buffer/rect types should unify with `tui-scrollbar`’s `ratatui-core` types, so we can
+//! render directly without copying.
+//! - Input: we currently translate `crossterm::MouseEvent` into `tui-scrollbar`’s backend-agnostic
+//! `ScrollEvent` types (and intentionally avoid `tui-scrollbar`’s optional `crossterm` adapter)
+//! to prevent multiple `crossterm` versions in the dependency graph. Once the Ratatui upgrade
+//! is complete, this should be revisited; if the workspace’s `crossterm` resolves to a single
+//! version, we can use `tui-scrollbar`’s adapter and reduce more local glue.
+
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui_core::buffer::Buffer as CoreBuffer;
+use ratatui_core::layout::Rect as CoreRect;
+use ratatui_core::widgets::Widget as _;
+use tui_scrollbar::PointerButton;
+use tui_scrollbar::PointerEvent;
+use tui_scrollbar::PointerEventKind;
+use tui_scrollbar::ScrollBar;
+use tui_scrollbar::ScrollBarInteraction;
+use tui_scrollbar::ScrollCommand;
+use tui_scrollbar::ScrollEvent;
+use tui_scrollbar::ScrollLengths;
+use tui_scrollbar::TrackClickBehavior;
+
+/// Number of columns reserved between transcript content and the scrollbar track.
+///
+/// This exists purely for visual separation and to avoid selection/copy UI feeling "attached" to
+/// the scrollbar.
+const TRANSCRIPT_SCROLLBAR_GAP_COLS: u16 = 1;
+/// Width of the scrollbar track itself (in terminal cells).
+///
+/// `tui-scrollbar` renders a vertical scrollbar into a 1-column area.
+const TRANSCRIPT_SCROLLBAR_TRACK_COLS: u16 = 1;
+/// Total columns reserved for transcript scrollbar UI (gap + track).
+pub(crate) const TRANSCRIPT_SCROLLBAR_COLS: u16 =
+ TRANSCRIPT_SCROLLBAR_GAP_COLS + TRANSCRIPT_SCROLLBAR_TRACK_COLS;
+
+/// Split a transcript viewport into content + scrollbar regions.
+///
+/// `codex-tui2` reserves space for the transcript scrollbar even when it is not visible so the
+/// transcript does not "reflow" when the user scrolls away from the bottom.
+///
+/// Layout:
+/// - `content_area`: original area minus [`TRANSCRIPT_SCROLLBAR_COLS`] on the right.
+/// - `scrollbar_area`: the last column of the original area (1 cell wide).
+/// - The remaining column (immediately left of `scrollbar_area`) is the "gap" and is intentionally
+/// left unused so the scrollbar reads as a separate affordance.
+///
+/// Returns `(area, None)` when the terminal is too narrow to reserve the required columns.
+pub(crate) fn split_transcript_area(area: Rect) -> (Rect, Option) {
+ if area.width <= TRANSCRIPT_SCROLLBAR_COLS {
+ return (area, None);
+ }
+
+ let content_width = area.width.saturating_sub(TRANSCRIPT_SCROLLBAR_COLS);
+ let content_area = Rect {
+ x: area.x,
+ y: area.y,
+ width: content_width,
+ height: area.height,
+ };
+ let scrollbar_area = Rect {
+ x: area.right().saturating_sub(1),
+ y: area.y,
+ width: TRANSCRIPT_SCROLLBAR_TRACK_COLS,
+ height: area.height,
+ };
+
+ (content_area, Some(scrollbar_area))
+}
+
+/// Whether the transcript scrollbar should be visible.
+///
+/// The scrollbar is treated as "active" when the transcript is scrollable and the viewport is not
+/// pinned to the bottom. This is used both for rendering (draw vs. keep blank) and for interaction
+/// (whether the scrollbar should be hit-testable).
+///
+/// Note that `codex-tui2` still reserves space for the scrollbar even when it is inactive; see
+/// [`split_transcript_area`].
+pub(crate) fn is_transcript_scrollbar_active(
+ total_lines: usize,
+ viewport_lines: usize,
+ top_offset: usize,
+) -> bool {
+ if total_lines <= viewport_lines {
+ return false;
+ }
+
+ let max_offset = total_lines.saturating_sub(viewport_lines);
+ top_offset < max_offset
+}
+
+/// Render the transcript scrollbar into `buf` when the viewport is scrolled away from bottom.
+///
+/// The scrollbar is hidden (but its column(s) remain reserved) while the viewport follows the
+/// latest output.
+///
+/// Implementation notes:
+/// - We keep `tui-scrollbar`’s default glyph selection and shape logic, but override colors to
+/// better match `codex-tui2`’s theme heuristics (see module docs).
+/// - Because `tui-scrollbar` renders into a `ratatui-core` buffer while `codex-tui2` uses `ratatui`
+/// (pre-0.30), we render into a scratch buffer and then copy the resulting symbols into the main
+/// buffer.
+pub(crate) fn render_transcript_scrollbar_if_active(
+ buf: &mut Buffer,
+ scrollbar_area: Option,
+ total_lines: usize,
+ viewport_lines: usize,
+ top_offset: usize,
+) {
+ let Some(scrollbar_area) = scrollbar_area else {
+ return;
+ };
+
+ if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
+ return;
+ }
+
+ if !is_transcript_scrollbar_active(total_lines, viewport_lines, top_offset) {
+ return;
+ }
+
+ let lengths = ScrollLengths {
+ content_len: total_lines,
+ viewport_len: viewport_lines,
+ };
+
+ let scrollbar = ScrollBar::vertical(lengths).offset(top_offset);
+
+ let core_bar_area = CoreRect {
+ x: scrollbar_area.x,
+ y: scrollbar_area.y,
+ width: scrollbar_area.width,
+ height: scrollbar_area.height,
+ };
+ let mut scratch = CoreBuffer::empty(core_bar_area);
+ (&scrollbar).render(core_bar_area, &mut scratch);
+
+ let (track_style, thumb_style) = scrollbar_styles();
+ for row in 0..scrollbar_area.height {
+ let x = scrollbar_area.x;
+ let y = scrollbar_area.y + row;
+ let src = &scratch[(x, y)];
+ let dst = &mut buf[(x, y)];
+ let symbol = src.symbol();
+ dst.set_symbol(symbol);
+ if symbol == " " {
+ dst.set_style(track_style);
+ } else {
+ dst.set_style(thumb_style);
+ }
+ }
+}
+
+/// Convert a `crossterm` mouse event into a requested transcript offset for the scrollbar.
+///
+/// This is a thin wrapper over `tui-scrollbar`’s pointer interaction logic:
+/// - It builds a `ScrollBar` configured with the current `top_offset`.
+/// - It translates the mouse event into a backend-agnostic [`ScrollEvent`].
+/// - It passes the event through `tui-scrollbar`’s hit testing and drag state (`interaction`).
+///
+/// `clamp_to_track` exists for `App`’s "pointer capture" behavior: once the user starts a drag on
+/// the scrollbar, we keep treating the gesture as a scrollbar drag even if the pointer moves
+/// outside the 1-column track. Without this clamp, the drag could stop producing offsets, and the
+/// same mouse gesture could then be interpreted as transcript selection.
+///
+/// Returns `None` when:
+/// - the scrollbar area is empty,
+/// - the transcript does not scroll (`total_lines <= viewport_lines`),
+/// - or the event is not a left-button down/drag/up.
+pub(crate) fn transcript_scrollbar_offset_for_mouse_event(
+ scrollbar_area: Rect,
+ total_lines: usize,
+ viewport_lines: usize,
+ top_offset: usize,
+ mut event: crossterm::event::MouseEvent,
+ interaction: &mut ScrollBarInteraction,
+ clamp_to_track: bool,
+) -> Option {
+ if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
+ return None;
+ }
+
+ if total_lines <= viewport_lines {
+ return None;
+ }
+
+ if clamp_to_track {
+ let max_x = scrollbar_area.right().saturating_sub(1);
+ let max_y = scrollbar_area.bottom().saturating_sub(1);
+ event.column = event.column.clamp(scrollbar_area.x, max_x);
+ event.row = event.row.clamp(scrollbar_area.y, max_y);
+ }
+
+ let lengths = ScrollLengths {
+ content_len: total_lines,
+ viewport_len: viewport_lines,
+ };
+ let scrollbar = ScrollBar::vertical(lengths)
+ .offset(top_offset)
+ .track_click_behavior(TrackClickBehavior::JumpToClick);
+
+ let core_bar_area = CoreRect {
+ x: scrollbar_area.x,
+ y: scrollbar_area.y,
+ width: scrollbar_area.width,
+ height: scrollbar_area.height,
+ };
+ let scroll_event = match event.kind {
+ crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
+ Some(ScrollEvent::Pointer(PointerEvent {
+ column: event.column,
+ row: event.row,
+ kind: PointerEventKind::Down,
+ button: PointerButton::Primary,
+ }))
+ }
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
+ Some(ScrollEvent::Pointer(PointerEvent {
+ column: event.column,
+ row: event.row,
+ kind: PointerEventKind::Up,
+ button: PointerButton::Primary,
+ }))
+ }
+ crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
+ Some(ScrollEvent::Pointer(PointerEvent {
+ column: event.column,
+ row: event.row,
+ kind: PointerEventKind::Drag,
+ button: PointerButton::Primary,
+ }))
+ }
+ _ => None,
+ };
+ scroll_event
+ .and_then(|scroll_event| scrollbar.handle_event(core_bar_area, scroll_event, interaction))
+ .map(|command| match command {
+ ScrollCommand::SetOffset(offset) => offset,
+ })
+}
+
+/// Derive track/thumb styles for the scrollbar from terminal defaults.
+///
+/// We prefer using the terminal’s default background/foreground so the scrollbar feels like a
+/// native part of the theme (and stays readable across 16-color / 256-color / truecolor
+/// backends).
+///
+/// When terminal defaults are unavailable (tests / unsupported terminals), we fall back to fixed
+/// ANSI colors that are likely to be visible.
+fn scrollbar_styles() -> (Style, Style) {
+ let Some(terminal_bg) = crate::terminal_palette::default_bg() else {
+ let track_style = Style::new().bg(Color::DarkGray);
+ let thumb_style = Style::new().fg(Color::Gray).bg(Color::DarkGray);
+ return (track_style, thumb_style);
+ };
+
+ let terminal_fg = crate::terminal_palette::default_fg();
+
+ let (track_rgb, thumb_rgb) = scrollbar_colors(terminal_bg, terminal_fg);
+ let track_bg = crate::terminal_palette::best_color(track_rgb);
+ let thumb_fg = crate::terminal_palette::best_color(thumb_rgb);
+
+ let track_style = Style::new().bg(track_bg);
+ let thumb_style = Style::new().fg(thumb_fg).bg(track_bg);
+ (track_style, thumb_style)
+}
+
+/// Compute `(track_bg_rgb, thumb_fg_rgb)` for the transcript scrollbar.
+///
+/// The scrollbar is styled to feel consistent with the user prompt background (see
+/// `style::user_message_bg`), but is tuned separately so the thumb reads as an inset control:
+///
+/// - Dark themes: track is a subtle brightening of the background; thumb is brighter than the track
+/// (but not pure white).
+/// - Light themes: track is a subtle darkening of the background; thumb is darker than the track.
+fn scrollbar_colors(
+ terminal_bg: (u8, u8, u8),
+ terminal_fg: Option<(u8, u8, u8)>,
+) -> ((u8, u8, u8), (u8, u8, u8)) {
+ let is_light = crate::color::is_light(terminal_bg);
+ let fallback_fg = if is_light { (0, 0, 0) } else { (255, 255, 255) };
+ let terminal_fg = terminal_fg.unwrap_or(fallback_fg);
+
+ // We want the scrollbar to feel visually related to the user message block background
+ // (`style::user_message_bg` uses 0.1), but slightly more subtle:
+ //
+ // - Light mode: keep both colors closer to the background (alpha < 0.1), with the thumb darker
+ // than the track.
+ // - Dark mode: keep the track slightly darker than the prompt block, but make the thumb
+ // brighter so it's easy to pick out without becoming "white".
+ let (track_alpha, thumb_alpha) = if is_light { (0.04, 0.08) } else { (0.08, 0.18) };
+
+ let track_rgb = crate::color::blend(terminal_fg, terminal_bg, track_alpha);
+ let thumb_rgb = crate::color::blend(terminal_fg, terminal_bg, thumb_alpha);
+ (track_rgb, thumb_rgb)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+
+ fn scrollbar_bg(buf: &Buffer, scrollbar_area: Rect) -> Vec {
+ use ratatui::style::Color;
+
+ let x = scrollbar_area.x;
+ (0..scrollbar_area.height)
+ .map(|row| {
+ buf[(x, scrollbar_area.y + row)]
+ .style()
+ .bg
+ .unwrap_or(Color::Reset)
+ })
+ .collect()
+ }
+
+ #[test]
+ fn does_not_render_when_pinned_to_bottom() {
+ let full_area = Rect::new(0, 0, 10, 6);
+ let (_, scrollbar_area) = split_transcript_area(full_area);
+ let mut buf = Buffer::empty(full_area);
+
+ render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 94);
+
+ assert_eq!(
+ scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
+ vec![
+ ratatui::style::Color::Reset,
+ ratatui::style::Color::Reset,
+ ratatui::style::Color::Reset,
+ ratatui::style::Color::Reset,
+ ratatui::style::Color::Reset,
+ ratatui::style::Color::Reset
+ ]
+ );
+ }
+
+ #[test]
+ fn renders_when_scrolled_away_from_bottom() {
+ let full_area = Rect::new(0, 0, 10, 6);
+ let (_, scrollbar_area) = split_transcript_area(full_area);
+ let mut buf = Buffer::empty(full_area);
+
+ render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 80);
+
+ assert_eq!(
+ scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
+ vec![
+ ratatui::style::Color::DarkGray,
+ ratatui::style::Color::DarkGray,
+ ratatui::style::Color::DarkGray,
+ ratatui::style::Color::DarkGray,
+ ratatui::style::Color::DarkGray,
+ ratatui::style::Color::DarkGray
+ ]
+ );
+ }
+
+ #[test]
+ fn split_leaves_gap_before_scrollbar() {
+ let full_area = Rect::new(0, 0, 10, 6);
+ let (content, scrollbar) = split_transcript_area(full_area);
+
+ assert_eq!(content.width, 8);
+ assert_eq!(scrollbar.expect("scrollbar").x, 9);
+ }
+
+ #[test]
+ fn scrollbar_mouse_drag_moves_offset_downward() {
+ use crossterm::event::KeyModifiers;
+ use crossterm::event::MouseButton;
+ use crossterm::event::MouseEvent;
+ use crossterm::event::MouseEventKind;
+
+ let scrollbar_area = Rect::new(9, 0, 1, 10);
+ let mut interaction = ScrollBarInteraction::new();
+
+ let down = MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: 9,
+ row: 0,
+ modifiers: KeyModifiers::empty(),
+ };
+ let mut offset = 0;
+ if let Some(next) = transcript_scrollbar_offset_for_mouse_event(
+ scrollbar_area,
+ 100,
+ 10,
+ offset,
+ down,
+ &mut interaction,
+ true,
+ ) {
+ offset = next;
+ }
+
+ let drag = MouseEvent {
+ kind: MouseEventKind::Drag(MouseButton::Left),
+ column: 9,
+ row: 9,
+ modifiers: KeyModifiers::empty(),
+ };
+ let dragged = transcript_scrollbar_offset_for_mouse_event(
+ scrollbar_area,
+ 100,
+ 10,
+ offset,
+ drag,
+ &mut interaction,
+ true,
+ )
+ .expect("drag should set an offset");
+
+ assert!(dragged > offset);
+ }
+
+ #[test]
+ fn light_mode_thumb_is_darker_than_track() {
+ let bg = (255, 255, 255);
+ let fg = Some((0, 0, 0));
+ let (track, thumb) = scrollbar_colors(bg, fg);
+
+ assert!(thumb.0 < track.0);
+ assert!(thumb.1 < track.1);
+ assert!(thumb.2 < track.2);
+ }
+
+ #[test]
+ fn dark_mode_thumb_is_brighter_than_track() {
+ let bg = (0, 0, 0);
+ let fg = Some((255, 255, 255));
+ let (track, thumb) = scrollbar_colors(bg, fg);
+
+ assert!(thumb.0 > track.0);
+ assert!(thumb.1 > track.1);
+ assert!(thumb.2 > track.2);
+ }
+}
diff --git a/codex-rs/tui2/src/transcript_scrollbar_ui.rs b/codex-rs/tui2/src/transcript_scrollbar_ui.rs
new file mode 100644
index 0000000000..92f19d1bc5
--- /dev/null
+++ b/codex-rs/tui2/src/transcript_scrollbar_ui.rs
@@ -0,0 +1,225 @@
+//! Transcript scrollbar mouse interaction.
+//!
+//! This module handles pointer interaction (click/drag) for the transcript scrollbar rendered by
+//! [`crate::transcript_scrollbar`]. It exists to keep `app.rs` from growing further: the transcript
+//! is a particularly stateful part of the UI (selection, wrapping, scroll anchoring, copy, etc.),
+//! and scrollbar interaction needs to coordinate with several of those subsystems.
+//!
+//! # Responsibilities
+//!
+//! - Translate `crossterm` mouse events into `tui-scrollbar` interaction events (backend-agnostic
+//! [`tui_scrollbar::ScrollEvent`]).
+//! - Maintain `tui-scrollbar`’s drag interaction state (`ScrollBarInteraction`) across frames so
+//! the thumb "grab offset" behaves naturally.
+//! - Maintain a tiny "pointer capture" flag so a drag that reaches the bottom doesn't fall through
+//! into transcript selection once the scrollbar becomes visually hidden (because the view is now
+//! pinned to bottom).
+//!
+//! This module does *not* render anything. Rendering lives in `transcript_scrollbar.rs`.
+//!
+//! # Interaction model and transcript anchors
+//!
+//! `tui-scrollbar` reports requested scroll positions as a raw `offset` (a top-row index). The
+//! transcript scroll state in `codex-tui2` is represented as a stable anchor
+//! ([`crate::tui::scrolling::TranscriptScroll`]) so it survives transcript growth and re-wrapping.
+//!
+//! The conversion happens here:
+//! - Ask `tui-scrollbar` for a `next_offset`.
+//! - Convert that concrete offset back into a stable anchor using
+//! [`crate::tui::scrolling::TranscriptScroll::anchor_for`].
+//! - If the requested offset is the bottom-most valid position, use `ToBottom` rather than a fixed
+//! anchor, restoring auto-follow behavior.
+//!
+//! This keeps scrollbar interaction consistent with other scroll mechanisms (wheel, PgUp/PgDn),
+//! which also operate in terms of the `TranscriptScroll` state machine.
+//!
+//! # Upgrade note: Ratatui 0.30+
+//!
+//! This module intentionally uses `tui-scrollbar`’s backend-agnostic event types instead of its
+//! optional `crossterm` adapter. The workspace uses a patched `crossterm`, and enabling the adapter
+//! would pull in a second `crossterm` version, making `MouseEvent` types incompatible.
+//!
+//! Once `codex-tui2` upgrades to Ratatui 0.30 (and the workspace converges on a single `crossterm`
+//! version), we should revisit whether we can remove this translation layer.
+
+use crate::history_cell::HistoryCell;
+use crate::transcript_scrollbar::is_transcript_scrollbar_active;
+use crate::transcript_scrollbar::transcript_scrollbar_offset_for_mouse_event;
+use crate::transcript_view_cache::TranscriptViewCache;
+use crate::tui;
+use crate::tui::scrolling::MouseScrollState;
+use crate::tui::scrolling::TranscriptScroll;
+use crossterm::event::MouseButton;
+use crossterm::event::MouseEvent;
+use crossterm::event::MouseEventKind;
+use ratatui::layout::Rect;
+use std::sync::Arc;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum TranscriptScrollbarMouseHandling {
+ /// The event is unrelated to the scrollbar; callers may handle it normally (e.g. selection).
+ NotHandled,
+ /// The event was handled by the scrollbar logic and should not be interpreted as selection.
+ Handled,
+}
+
+/// Persistent UI state for transcript scrollbar pointer interaction.
+///
+/// This stores `tui-scrollbar`’s drag state (`ScrollBarInteraction`) plus a small "pointer capture"
+/// flag used by `codex-tui2`:
+///
+/// - When the user clicks the scrollbar thumb/track, we enter pointer capture.
+/// - While capture is active, subsequent drag events are treated as scrollbar drags even if the
+/// pointer leaves the 1-column track.
+/// - Capture is released on `MouseUp`.
+///
+/// The capture flag is important because the transcript scrollbar is hidden while pinned to
+/// bottom; without capture, a drag that reaches the bottom could stop producing offsets and fall
+/// through into transcript selection mid-gesture.
+#[derive(Debug, Default)]
+pub(crate) struct TranscriptScrollbarUi {
+ interaction: tui_scrollbar::ScrollBarInteraction,
+ pointer_capture: bool,
+}
+
+/// Bundles the arguments needed to handle a transcript scrollbar mouse event.
+///
+/// This is intentionally a struct (rather than a long argument list) because scrollbar interaction
+/// touches several pieces of transcript state at once: wrapping cache, scroll anchor state, the
+/// concrete top-row offset, and the wheel-scroll stream state machine. Grouping them makes call
+/// sites easier to scan and helps keep `app.rs` glue minimal.
+pub(crate) struct TranscriptScrollbarMouseEvent<'a> {
+ pub(crate) tui: &'a mut tui::Tui,
+ pub(crate) mouse_event: MouseEvent,
+ pub(crate) transcript_area: Rect,
+ pub(crate) scrollbar_area: Option,
+ pub(crate) transcript_cells: &'a [Arc],
+ pub(crate) transcript_view_cache: &'a mut TranscriptViewCache,
+ pub(crate) transcript_scroll: &'a mut TranscriptScroll,
+ pub(crate) transcript_view_top: &'a mut usize,
+ pub(crate) transcript_total_lines: &'a mut usize,
+ pub(crate) mouse_scroll_state: &'a mut MouseScrollState,
+}
+
+impl TranscriptScrollbarUi {
+ pub(crate) fn pointer_capture_active(&self) -> bool {
+ self.pointer_capture
+ }
+
+ /// Handle click/drag events for the transcript scrollbar.
+ ///
+ /// The caller is expected to provide the transcript layout for the current terminal size:
+ /// `transcript_area` for content and `scrollbar_area` for the 1-column scrollbar track. See
+ /// [`crate::transcript_scrollbar::split_transcript_area`].
+ ///
+ /// Returns [`TranscriptScrollbarMouseHandling::Handled`] when the event should not be
+ /// interpreted as transcript selection (either because it updated the scroll position or
+ /// because an in-progress scrollbar drag is being captured).
+ pub(crate) fn handle_mouse_event(
+ &mut self,
+ event: TranscriptScrollbarMouseEvent<'_>,
+ ) -> TranscriptScrollbarMouseHandling {
+ let TranscriptScrollbarMouseEvent {
+ tui,
+ mouse_event,
+ transcript_area,
+ scrollbar_area,
+ transcript_cells,
+ transcript_view_cache,
+ transcript_scroll,
+ transcript_view_top,
+ transcript_total_lines,
+ mouse_scroll_state,
+ } = event;
+ let is_scrollbar_event = matches!(
+ mouse_event.kind,
+ MouseEventKind::Down(MouseButton::Left)
+ | MouseEventKind::Drag(MouseButton::Left)
+ | MouseEventKind::Up(MouseButton::Left)
+ );
+ if !is_scrollbar_event {
+ return TranscriptScrollbarMouseHandling::NotHandled;
+ }
+
+ let Some(scrollbar_area) = scrollbar_area else {
+ if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
+ self.pointer_capture = false;
+ }
+ return if self.pointer_capture {
+ TranscriptScrollbarMouseHandling::Handled
+ } else {
+ TranscriptScrollbarMouseHandling::NotHandled
+ };
+ };
+
+ let is_over_scrollbar = mouse_event.column >= scrollbar_area.x
+ && mouse_event.column < scrollbar_area.right()
+ && mouse_event.row >= scrollbar_area.y
+ && mouse_event.row < scrollbar_area.bottom();
+
+ if !self.pointer_capture && !is_over_scrollbar {
+ return TranscriptScrollbarMouseHandling::NotHandled;
+ }
+
+ let viewport_lines = transcript_area.height as usize;
+ let scrollbar_is_visible = if viewport_lines > 0 && !transcript_cells.is_empty() {
+ transcript_view_cache.ensure_wrapped(transcript_cells, transcript_area.width);
+ let total_lines = transcript_view_cache.lines().len();
+ let max_visible = std::cmp::min(total_lines, viewport_lines);
+ is_transcript_scrollbar_active(total_lines, max_visible, *transcript_view_top)
+ } else {
+ false
+ };
+
+ // When the transcript is pinned to bottom, we intentionally hide the scrollbar (but still
+ // reserve its column). In that state, we avoid hit-testing the scrollbar track so the
+ // reserved column doesn't become an invisible interactive region. Pointer capture remains
+ // active for an in-progress drag so a gesture that reaches the bottom doesn't fall through
+ // into transcript selection mid-drag.
+ if !self.pointer_capture && !scrollbar_is_visible {
+ return TranscriptScrollbarMouseHandling::NotHandled;
+ }
+
+ if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) && is_over_scrollbar
+ {
+ self.pointer_capture = true;
+ }
+
+ if viewport_lines > 0 && !transcript_cells.is_empty() {
+ // `ensure_wrapped` was already called above when checking visibility.
+ let total_lines = transcript_view_cache.lines().len();
+ let max_visible = std::cmp::min(total_lines, viewport_lines);
+ let max_offset = total_lines.saturating_sub(max_visible);
+
+ if let Some(next_offset) = transcript_scrollbar_offset_for_mouse_event(
+ scrollbar_area,
+ total_lines,
+ max_visible,
+ *transcript_view_top,
+ mouse_event,
+ &mut self.interaction,
+ self.pointer_capture,
+ ) {
+ let next_offset = next_offset.min(max_offset);
+ let line_meta = transcript_view_cache.line_meta();
+
+ *transcript_scroll = if next_offset >= max_offset {
+ TranscriptScroll::ToBottom
+ } else {
+ TranscriptScroll::anchor_for(line_meta, next_offset)
+ .unwrap_or(TranscriptScroll::ToBottom)
+ };
+ *transcript_view_top = next_offset.min(max_offset);
+ *transcript_total_lines = total_lines;
+ *mouse_scroll_state = MouseScrollState::default();
+ tui.frame_requester().schedule_frame();
+ }
+ }
+
+ if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
+ self.pointer_capture = false;
+ }
+
+ TranscriptScrollbarMouseHandling::Handled
+ }
+}
diff --git a/codex-rs/tui2/src/transcript_view_cache.rs b/codex-rs/tui2/src/transcript_view_cache.rs
new file mode 100644
index 0000000000..a32094b118
--- /dev/null
+++ b/codex-rs/tui2/src/transcript_view_cache.rs
@@ -0,0 +1,1033 @@
+//! Caches for transcript rendering in `codex-tui2`.
+//!
+//! The inline transcript view is drawn every frame. Two parts of that draw can
+//! be expensive in steady state:
+//!
+//! - Building the *wrapped transcript* (`HistoryCell` → flattened `Line`s +
+//! per-line metadata). This work is needed for rendering and for scroll math.
+//! - Rendering each visible `Line` into the frame buffer. Ratatui's rendering
+//! path performs grapheme segmentation and width/layout work; repeatedly
+//! rerendering the same visible lines can dominate CPU during streaming.
+//!
+//! This module provides a pair of caches:
+//!
+//! - [`WrappedTranscriptCache`] memoizes the wrapped transcript for a given
+//! terminal width and supports incremental append when new history cells are
+//! added.
+//! - [`TranscriptRasterCache`] memoizes the *rasterized* representation of
+//! individual wrapped lines (a single terminal row of `Cell`s) so redraws can
+//! cheaply copy already-rendered cells instead of re-running grapheme
+//! segmentation for every frame.
+//!
+//! Notes:
+//! - All caches are invalidated on width changes because wrapping and layout
+//! depend on the viewport width.
+//! - Rasterization is cached for base transcript content only; selection
+//! highlight and copy affordances are applied after the rows are drawn, so
+//! they do not pollute the cache.
+//!
+//! ## Algorithm overview
+//!
+//! At a high level, transcript rendering is a two-stage pipeline:
+//!
+//! 1. **Build wrapped transcript lines**: flatten the logical `HistoryCell` list into a single
+//! vector of visual [`Line`]s and a parallel `meta` vector (`TranscriptLineMeta`) that maps each
+//! visual line back to `(cell_index, line_in_cell)` or `Spacer`.
+//! 2. **Render visible lines into the frame buffer**: draw the subset of wrapped lines that are
+//! currently visible in the viewport.
+//!
+//! The cache mirrors that pipeline:
+//!
+//! - [`WrappedTranscriptCache`] memoizes stage (1) for the current `width` and supports incremental
+//! append when new cells are pushed during streaming.
+//! - [`TranscriptRasterCache`] memoizes stage (2) per line by caching the final rendered row
+//! (`Vec`) for a given `(line_index, is_user_row)` at the current `width`.
+//!
+//! ### Per draw tick
+//!
+//! Callers typically do the following during a draw tick:
+//!
+//! 1. Call [`TranscriptViewCache::ensure_wrapped`] with the current `cells` and viewport `width`.
+//! This may append new cells or rebuild from scratch (on width change/truncation/replacement).
+//! 2. Use [`TranscriptViewCache::lines`] and [`TranscriptViewCache::line_meta`] for scroll math and
+//! to resolve the visible `line_index` range.
+//! 3. Configure row caching via [`TranscriptViewCache::set_raster_capacity`] (usually a few
+//! viewports worth).
+//! 4. For each visible `line_index`, call [`TranscriptViewCache::render_row_index_into`] to draw a
+//! single terminal row.
+//!
+//! ### Rasterization details
+//!
+//! `render_row_index_into` delegates to `TranscriptRasterCache::render_row_into`:
+//!
+//! - On a **cache hit**, it copies cached cells into the destination buffer (no grapheme
+//! segmentation, no span layout).
+//! - On a **cache miss**, it renders the wrapped [`Line`] into a scratch `Buffer` with height 1,
+//! copies out the resulting cells, inserts them into the cache, and then copies them into the
+//! destination buffer.
+//!
+//! Cached rows are invalidated when:
+//! - the wrapped transcript is rebuilt (line indices shift)
+//! - the width changes (layout changes)
+//!
+//! The raster cache is bounded by `capacity` using an approximate LRU so it does not grow without
+//! bound during long sessions.
+
+use crate::history_cell::HistoryCell;
+use crate::history_cell::UserHistoryCell;
+use crate::transcript_render::TranscriptLines;
+use crate::tui::scrolling::TranscriptLineMeta;
+use ratatui::buffer::Buffer;
+use ratatui::prelude::Rect;
+use ratatui::text::Line;
+use ratatui::widgets::WidgetRef;
+use std::collections::HashMap;
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+/// Top-level cache for the inline transcript viewport.
+///
+/// This combines two caches that are used together during a draw tick:
+///
+/// - [`WrappedTranscriptCache`] produces the flattened wrapped transcript lines and metadata used
+/// for rendering, scrolling, and selection/copy mapping.
+/// - [`TranscriptRasterCache`] caches the expensive conversion from a wrapped [`Line`] into a row
+/// of terminal [`ratatui::buffer::Cell`]s so repeated redraws can copy cells instead of redoing
+/// grapheme segmentation.
+///
+/// The caches are intentionally coupled:
+/// - width changes invalidate both layers
+/// - wrapped transcript rebuilds invalidate the raster cache because line indices shift
+pub(crate) struct TranscriptViewCache {
+ /// Memoized flattened wrapped transcript content for the current width.
+ wrapped: WrappedTranscriptCache,
+ /// Per-line row rasterization cache for the current width.
+ raster: TranscriptRasterCache,
+}
+
+impl TranscriptViewCache {
+ /// Create an empty transcript view cache.
+ pub(crate) fn new() -> Self {
+ Self {
+ wrapped: WrappedTranscriptCache::new(),
+ raster: TranscriptRasterCache::new(),
+ }
+ }
+
+ /// Ensure the wrapped transcript cache is up to date for `cells` at `width`.
+ ///
+ /// This is the shared entrypoint for the transcript renderer and scroll math. It ensures the
+ /// cache reflects the current transcript and viewport width while preserving scroll/copy
+ /// invariants (`lines`, `meta`, and `joiner_before` remain aligned).
+ ///
+ /// Rebuild conditions:
+ /// - `width` changes (wrapping/layout is width-dependent)
+ /// - the transcript is truncated (fewer `cells` than last time), which means the previously
+ /// cached suffix may refer to cells that no longer exist and the cached `(cell_index,
+ /// line_in_cell)` mapping is no longer valid. In `tui2` today, this happens when the user
+ /// backtracks/forks a conversation: `app_backtrack` trims `App::transcript_cells` to preserve
+ /// only content up to the selected user message.
+ /// - the transcript is replaced (detected by a change in the first cell pointer), which
+ /// commonly happens when history is rotated/dropped from the front while keeping a similar
+ /// length (e.g. to cap history size) or when switching to a different transcript. We don't
+ /// currently replace the transcript list in the main render loop, but we keep this guard so
+ /// future history-capping or transcript-reload features can't accidentally treat a shifted
+ /// list as an append. In that case, treating the new list as an append would misattribute
+ /// line origins and break scroll anchors and selection/copy mapping.
+ ///
+ /// The raster cache is invalidated whenever the wrapped transcript is rebuilt or the width no
+ /// longer matches.
+ pub(crate) fn ensure_wrapped(&mut self, cells: &[Arc], width: u16) {
+ let update = self.wrapped.ensure(cells, width);
+ if update == WrappedTranscriptUpdate::Rebuilt {
+ self.raster.width = width;
+ self.raster.clear();
+ } else if width != self.raster.width {
+ // Keep the invariant that raster cache always matches the active wrapped width.
+ self.raster.clear();
+ self.raster.width = width;
+ }
+ }
+
+ /// Return the cached flattened wrapped transcript lines.
+ ///
+ /// This is primarily used for:
+ /// - computing `total_lines` for scroll/viewport logic
+ /// - any code that needs a read-only view of the current flattened transcript
+ ///
+ /// Callers should generally avoid iterating these lines to render them in the draw hot path;
+ /// use [`Self::render_row_index_into`] so redraws can take advantage of the raster cache.
+ pub(crate) fn lines(&self) -> &[Line<'static>] {
+ &self.wrapped.transcript.lines
+ }
+
+ /// Return per-line origin metadata aligned with [`Self::lines`].
+ ///
+ /// This mapping is what makes scroll/selection stable as the transcript grows and reflows:
+ /// each visible line index can be mapped back to the originating `(cell_index, line_in_cell)`
+ /// pair (or to a `Spacer` row).
+ ///
+ /// Typical uses:
+ /// - scroll anchoring (`TranscriptScroll` resolves/anchors using this metadata)
+ /// - determining whether a visible row is a user-authored row (`cell_index → is_user_cell`)
+ pub(crate) fn line_meta(&self) -> &[TranscriptLineMeta] {
+ &self.wrapped.transcript.meta
+ }
+
+ /// Configure the per-line raster cache capacity.
+ ///
+ /// When `capacity == 0`, raster caching is disabled and rows are rendered directly into the
+ /// destination buffer (but wrapped transcript caching still applies).
+ pub(crate) fn set_raster_capacity(&mut self, capacity: usize) {
+ self.raster.set_capacity(capacity);
+ }
+
+ /// Whether a flattened transcript line belongs to a user-authored history cell.
+ ///
+ /// User rows apply a row-wide base style (background). This is a property of the originating
+ /// cell, not of the line content, so it is derived from the cached `line_meta` mapping.
+ pub(crate) fn is_user_row(&self, line_index: usize) -> bool {
+ let Some(cell_index) = self
+ .wrapped
+ .transcript
+ .meta
+ .get(line_index)
+ .and_then(TranscriptLineMeta::cell_index)
+ else {
+ return false;
+ };
+
+ self.wrapped
+ .is_user_cell
+ .get(cell_index)
+ .copied()
+ .unwrap_or(false)
+ }
+
+ /// Render a single cached line index into the destination `buf`.
+ ///
+ /// This is the draw hot-path helper: it looks up the wrapped `Line` for `line_index`, applies
+ /// user-row styling if needed, and then either rasterizes the line or copies cached cells into
+ /// place.
+ ///
+ /// Callers are expected to have already ensured the cache via [`Self::ensure_wrapped`].
+ pub(crate) fn render_row_index_into(
+ &mut self,
+ line_index: usize,
+ row_area: Rect,
+ buf: &mut Buffer,
+ ) {
+ let is_user_row = self.is_user_row(line_index);
+ let line = &self.wrapped.transcript.lines[line_index];
+ self.raster
+ .render_row_into(line_index, is_user_row, line, row_area, buf);
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum WrappedTranscriptUpdate {
+ /// The cache already represented the provided `cells` and `width`.
+ Unchanged,
+ /// The cache appended additional cells without rebuilding.
+ Appended,
+ /// The cache rebuilt from scratch (width change, truncation, or replacement).
+ Rebuilt,
+}
+
+/// Incremental memoization of wrapped transcript lines for a given width.
+///
+/// This cache exists so callers doing tight-loop scroll math (mouse wheel, PgUp/PgDn) and render
+/// ticks do not repeatedly rebuild the wrapped transcript (`HistoryCell` → flattened `Line`s).
+///
+/// It assumes the transcript is append-mostly: when new cells arrive, they are appended to the end
+/// of `cells` and existing cells do not mutate. If the underlying cell list is replaced or
+/// truncated, the cache rebuilds from scratch.
+struct WrappedTranscriptCache {
+ /// Width this cache was last built for.
+ width: u16,
+ /// Number of leading cells already incorporated into [`Self::transcript`].
+ cell_count: usize,
+ /// Pointer identity of the first cell at the time the cache was built.
+ ///
+ /// This is a cheap replacement/truncation detector: if the caller swaps the transcript list
+ /// (for example, drops old cells from the front to cap history length), the length may remain
+ /// the same while the content shifts. In that case, we must rebuild because `(cell_index,
+ /// line_in_cell)` mappings and scroll anchors would otherwise become inconsistent.
+ first_cell_ptr: Option<*const dyn HistoryCell>,
+ /// Cached flattened wrapped transcript output.
+ ///
+ /// Invariant: `lines.len() == meta.len() == joiner_before.len()`.
+ transcript: TranscriptLines,
+ /// Whether the flattened transcript has emitted at least one non-spacer line.
+ ///
+ /// This is used to decide whether to insert a spacer line between non-continuation cells.
+ has_emitted_lines: bool,
+ /// Per-cell marker indicating whether a logical cell is a [`UserHistoryCell`].
+ ///
+ /// We store this alongside the wrapped transcript so user-row styling can be derived cheaply
+ /// from `TranscriptLineMeta::cell_index()` without re-inspecting the cell type every frame.
+ is_user_cell: Vec,
+}
+
+impl WrappedTranscriptCache {
+ /// Create an empty wrapped transcript cache.
+ ///
+ /// The cache is inert until the first [`Self::ensure`] call; until then it contains no
+ /// rendered transcript state.
+ fn new() -> Self {
+ Self {
+ width: 0,
+ cell_count: 0,
+ first_cell_ptr: None,
+ transcript: TranscriptLines {
+ lines: Vec::new(),
+ meta: Vec::new(),
+ joiner_before: Vec::new(),
+ },
+ has_emitted_lines: false,
+ is_user_cell: Vec::new(),
+ }
+ }
+
+ /// Ensure the wrapped transcript represents `cells` at `width`.
+ ///
+ /// This cache is intentionally single-entry and width-scoped:
+ /// - when `width` is unchanged and `cells` has grown, append only the new cells
+ /// - when `width` changes or the transcript is replaced/truncated, rebuild from scratch
+ ///
+ /// The cache assumes history cells are append-only and immutable once inserted. If existing
+ /// cell contents can change without changing identity, callers must treat that as a rebuild.
+ fn ensure(&mut self, cells: &[Arc], width: u16) -> WrappedTranscriptUpdate {
+ if width == 0 {
+ self.width = width;
+ self.cell_count = cells.len();
+ self.first_cell_ptr = cells.first().map(Arc::as_ptr);
+ self.transcript.lines.clear();
+ self.transcript.meta.clear();
+ self.transcript.joiner_before.clear();
+ self.has_emitted_lines = false;
+ self.is_user_cell.clear();
+ return WrappedTranscriptUpdate::Rebuilt;
+ }
+
+ let current_first_ptr = cells.first().map(Arc::as_ptr);
+ if self.width != width
+ || self.cell_count > cells.len()
+ || (self.cell_count > 0
+ && current_first_ptr.is_some()
+ && self.first_cell_ptr != current_first_ptr)
+ {
+ self.rebuild(cells, width);
+ return WrappedTranscriptUpdate::Rebuilt;
+ }
+
+ if self.cell_count == cells.len() {
+ return WrappedTranscriptUpdate::Unchanged;
+ }
+
+ let old_cell_count = self.cell_count;
+ self.cell_count = cells.len();
+ self.first_cell_ptr = current_first_ptr;
+ let base_opts: crate::wrapping::RtOptions<'_> =
+ crate::wrapping::RtOptions::new(width.max(1) as usize);
+ for (cell_index, cell) in cells.iter().enumerate().skip(old_cell_count) {
+ self.is_user_cell
+ .push(cell.as_any().is::());
+ crate::transcript_render::append_wrapped_transcript_cell(
+ &mut self.transcript,
+ &mut self.has_emitted_lines,
+ cell_index,
+ cell,
+ width,
+ &base_opts,
+ );
+ }
+
+ WrappedTranscriptUpdate::Appended
+ }
+
+ /// Rebuild the wrapped transcript cache from scratch.
+ ///
+ /// This is used when width changes, the transcript is truncated, or the caller provides a new
+ /// cell list that cannot be treated as an append to the previous one.
+ fn rebuild(&mut self, cells: &[Arc], width: u16) {
+ self.width = width;
+ self.cell_count = cells.len();
+ self.first_cell_ptr = cells.first().map(Arc::as_ptr);
+ self.transcript.lines.clear();
+ self.transcript.meta.clear();
+ self.transcript.joiner_before.clear();
+ self.has_emitted_lines = false;
+ self.is_user_cell.clear();
+ self.is_user_cell.reserve(cells.len());
+
+ let base_opts: crate::wrapping::RtOptions<'_> =
+ crate::wrapping::RtOptions::new(width.max(1) as usize);
+ for (cell_index, cell) in cells.iter().enumerate() {
+ self.is_user_cell
+ .push(cell.as_any().is::());
+ crate::transcript_render::append_wrapped_transcript_cell(
+ &mut self.transcript,
+ &mut self.has_emitted_lines,
+ cell_index,
+ cell,
+ width,
+ &base_opts,
+ );
+ }
+ }
+}
+
+/// Bounded cache of rasterized transcript rows.
+///
+/// Each cached entry stores the final rendered [`ratatui::buffer::Cell`] values for a single
+/// transcript line rendered into a 1-row buffer.
+///
+/// Keying:
+/// - The cache key includes `(line_index, is_user_row)`.
+/// - Width is stored out-of-band and any width change clears the cache.
+///
+/// Eviction:
+/// - The cache uses an approximate LRU implemented with a monotonic stamp (`clock`) and an
+/// `(key, stamp)` queue.
+/// - This avoids per-access list manipulation while still keeping memory bounded.
+struct TranscriptRasterCache {
+ /// Width this cache's rasterized rows were rendered at.
+ width: u16,
+ /// Maximum number of rasterized rows to retain.
+ capacity: usize,
+ /// Monotonic counter used to stamp accesses for eviction.
+ clock: u64,
+ /// Version of the terminal palette used for the cached rows.
+ palette_version: u64,
+ /// Access log used for approximate LRU eviction.
+ lru: VecDeque<(u64, u64)>,
+ /// Cached rasterized rows by key.
+ rows: HashMap,
+}
+
+/// Cached raster for a single transcript line at a particular width.
+#[derive(Clone)]
+struct RasterizedRow {
+ /// The last access stamp recorded for this row.
+ ///
+ /// Eviction only removes a row when a popped `(key, stamp)` matches this value.
+ last_used: u64,
+ /// The full row of rendered cells (length is `width` at the time of rasterization).
+ cells: Vec,
+}
+
+impl TranscriptRasterCache {
+ /// Create an empty raster cache (caching disabled until a non-zero capacity is set).
+ fn new() -> Self {
+ Self {
+ width: 0,
+ capacity: 0,
+ clock: 0,
+ palette_version: crate::terminal_palette::palette_version(),
+ lru: VecDeque::new(),
+ rows: HashMap::new(),
+ }
+ }
+
+ /// Drop all cached rasterized rows and reset access tracking.
+ ///
+ /// This is used on width changes and when disabling caching so we don't retain stale rows or
+ /// unbounded memory.
+ fn clear(&mut self) {
+ self.lru.clear();
+ self.rows.clear();
+ self.clock = 0;
+ }
+
+ /// Set the maximum number of cached rasterized rows.
+ ///
+ /// When set to 0, caching is disabled and any existing cached rows are dropped.
+ fn set_capacity(&mut self, capacity: usize) {
+ self.capacity = capacity;
+ self.evict_if_needed();
+ }
+
+ /// Render a single wrapped transcript line into `buf`, using a cached raster when possible.
+ ///
+ /// The cache key includes `is_user_row` because user rows apply a row-wide base style, so the
+ /// final raster differs even when the text spans are identical.
+ fn render_row_into(
+ &mut self,
+ line_index: usize,
+ is_user_row: bool,
+ line: &Line<'static>,
+ row_area: Rect,
+ buf: &mut Buffer,
+ ) {
+ if row_area.width == 0 || row_area.height == 0 {
+ return;
+ }
+
+ let palette_version = crate::terminal_palette::palette_version();
+ if palette_version != self.palette_version {
+ self.palette_version = palette_version;
+ self.clear();
+ }
+
+ if self.width != row_area.width {
+ self.width = row_area.width;
+ self.clear();
+ }
+
+ if self.capacity == 0 {
+ let cells = rasterize_line(line, row_area.width, is_user_row);
+ copy_row(row_area, buf, &cells);
+ return;
+ }
+
+ let key = raster_key(line_index, is_user_row);
+ let stamp = self.bump_clock();
+ if let Some(row) = self.rows.get_mut(&key) {
+ row.last_used = stamp;
+ self.lru.push_back((key, stamp));
+ copy_row(row_area, buf, &row.cells);
+ return;
+ }
+
+ let cells = rasterize_line(line, row_area.width, is_user_row);
+ copy_row(row_area, buf, &cells);
+ self.rows.insert(
+ key,
+ RasterizedRow {
+ last_used: stamp,
+ cells,
+ },
+ );
+ self.lru.push_back((key, stamp));
+ self.evict_if_needed();
+ }
+
+ /// Return a new access stamp.
+ ///
+ /// The stamp is used only for equality checks ("is this the latest access for this key?") so a
+ /// wrapping counter is sufficient; `u64` wraparound is effectively unreachable in practice for
+ /// a UI cache.
+ fn bump_clock(&mut self) -> u64 {
+ let stamp = self.clock;
+ self.clock = self.clock.wrapping_add(1);
+ stamp
+ }
+
+ /// Evict old cached rows until `rows.len() <= capacity`.
+ ///
+ /// The cache uses an approximate LRU: we push `(key, stamp)` on every access, and only evict a
+ /// row when the popped entry matches the row's current `last_used` stamp.
+ fn evict_if_needed(&mut self) {
+ if self.capacity == 0 {
+ self.clear();
+ return;
+ }
+ while self.rows.len() > self.capacity {
+ let Some((key, stamp)) = self.lru.pop_front() else {
+ break;
+ };
+ if self
+ .rows
+ .get(&key)
+ .is_some_and(|row| row.last_used == stamp)
+ {
+ self.rows.remove(&key);
+ }
+ }
+ }
+}
+
+/// Compute the cache key for a rasterized transcript row.
+///
+/// We key by `line_index` (not by hashing line content) because:
+/// - it is effectively free in the draw loop
+/// - the wrapped transcript cache defines a stable `(index → Line)` mapping until the next rebuild
+/// - rebuilds clear the raster cache, so indices cannot alias across different transcripts
+///
+/// `is_user_row` is included because user rows apply a row-wide base style that affects every cell.
+fn raster_key(line_index: usize, is_user_row: bool) -> u64 {
+ (line_index as u64) << 1 | u64::from(is_user_row)
+}
+
+/// Rasterize a single wrapped transcript [`Line`] into a 1-row cell vector.
+///
+/// This is the expensive step we want to avoid repeating on every redraw: it runs Ratatui's
+/// rendering for the line (including grapheme segmentation) into a scratch buffer and then copies
+/// out the rendered cells.
+///
+/// For user rows, we pre-fill the row with the base user style so the cached raster includes the
+/// full-width background, matching the viewport behavior.
+fn rasterize_line(
+ line: &Line<'static>,
+ width: u16,
+ is_user_row: bool,
+) -> Vec {
+ let scratch_area = Rect::new(0, 0, width, 1);
+ let mut scratch = Buffer::empty(scratch_area);
+
+ if is_user_row {
+ let base_style = crate::style::user_message_style();
+ for x in 0..width {
+ scratch[(x, 0)].set_style(base_style);
+ }
+ }
+
+ line.render_ref(scratch_area, &mut scratch);
+
+ let mut out = Vec::with_capacity(width as usize);
+ for x in 0..width {
+ out.push(scratch[(x, 0)].clone());
+ }
+ out
+}
+
+/// Copy a cached rasterized row into a destination buffer at `area`.
+///
+/// This is the "fast path" for redraws: once a row is cached, a redraw copies the pre-rendered
+/// cells into the frame buffer without re-running span layout/grapheme segmentation.
+fn copy_row(area: Rect, buf: &mut Buffer, cells: &[ratatui::buffer::Cell]) {
+ let y = area.y;
+ for (dx, cell) in cells.iter().enumerate() {
+ let x = area.x.saturating_add(dx as u16);
+ if x >= area.right() {
+ break;
+ }
+ buf[(x, y)] = cell.clone();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::history_cell::TranscriptLinesWithJoiners;
+ use crate::history_cell::UserHistoryCell;
+ use pretty_assertions::assert_eq;
+ use ratatui::style::Color;
+ use ratatui::style::Style;
+ use ratatui::style::Stylize;
+ use ratatui::text::Span;
+ use std::sync::atomic::AtomicUsize;
+ use std::sync::atomic::Ordering;
+
+ #[derive(Debug)]
+ struct FakeCell {
+ lines: Vec>,
+ joiner_before: Vec>,
+ is_stream_continuation: bool,
+ transcript_calls: Arc,
+ }
+
+ impl FakeCell {
+ fn new(
+ lines: Vec>,
+ joiner_before: Vec>,
+ is_stream_continuation: bool,
+ transcript_calls: Arc,
+ ) -> Self {
+ Self {
+ lines,
+ joiner_before,
+ is_stream_continuation,
+ transcript_calls,
+ }
+ }
+ }
+
+ impl HistoryCell for FakeCell {
+ fn display_lines(&self, _width: u16) -> Vec> {
+ self.lines.clone()
+ }
+
+ fn transcript_lines_with_joiners(&self, _width: u16) -> TranscriptLinesWithJoiners {
+ self.transcript_calls.fetch_add(1, Ordering::Relaxed);
+ TranscriptLinesWithJoiners {
+ lines: self.lines.clone(),
+ joiner_before: self.joiner_before.clone(),
+ }
+ }
+
+ fn is_stream_continuation(&self) -> bool {
+ self.is_stream_continuation
+ }
+ }
+
+ #[test]
+ fn wrapped_cache_matches_build_wrapped_transcript_lines() {
+ let calls0 = Arc::new(AtomicUsize::new(0));
+ let calls1 = Arc::new(AtomicUsize::new(0));
+ let calls2 = Arc::new(AtomicUsize::new(0));
+
+ let cells: Vec> = vec![
+ // Wrapping case: expect a soft-wrap joiner for the continuation segment.
+ Arc::new(FakeCell::new(
+ vec![Line::from("• hello world")],
+ vec![None],
+ false,
+ calls0,
+ )),
+ // Preformatted (cyan) lines are not wrapped by the viewport wrapper.
+ Arc::new(FakeCell::new(
+ vec![Line::from(" let x = 12345;").cyan()],
+ vec![None],
+ true,
+ calls1,
+ )),
+ // New non-continuation cell inserts a spacer.
+ Arc::new(FakeCell::new(
+ vec![Line::from("• foo bar")],
+ vec![None],
+ false,
+ calls2,
+ )),
+ ];
+
+ let width = 8;
+ let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells, width);
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&cells, width);
+
+ assert_eq!(cache.lines(), expected.lines.as_slice());
+ assert_eq!(cache.line_meta(), expected.meta.as_slice());
+ assert_eq!(
+ cache.wrapped.transcript.joiner_before,
+ expected.joiner_before
+ );
+ assert_eq!(cache.lines().len(), cache.line_meta().len());
+ assert_eq!(
+ cache.lines().len(),
+ cache.wrapped.transcript.joiner_before.len()
+ );
+ }
+
+ #[test]
+ fn wrapped_cache_ensure_appends_only_new_cells_when_width_is_unchanged() {
+ let calls0 = Arc::new(AtomicUsize::new(0));
+ let calls1 = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![
+ Arc::new(FakeCell::new(
+ vec![Line::from("• hello world")],
+ vec![None],
+ false,
+ calls0.clone(),
+ )),
+ Arc::new(FakeCell::new(
+ vec![Line::from("• foo bar")],
+ vec![None],
+ false,
+ calls1.clone(),
+ )),
+ ];
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&cells[..1], 8);
+ cache.ensure_wrapped(&cells, 8);
+
+ assert_eq!(calls0.load(Ordering::Relaxed), 1);
+ assert_eq!(calls1.load(Ordering::Relaxed), 1);
+
+ assert_eq!(
+ cache.lines(),
+ &[
+ Line::from("• hello"),
+ Line::from("world"),
+ Line::from(""),
+ Line::from("• foo"),
+ Line::from("bar")
+ ]
+ );
+ assert_eq!(
+ cache.line_meta(),
+ &[
+ TranscriptLineMeta::CellLine {
+ cell_index: 0,
+ line_in_cell: 0
+ },
+ TranscriptLineMeta::CellLine {
+ cell_index: 0,
+ line_in_cell: 1
+ },
+ TranscriptLineMeta::Spacer,
+ TranscriptLineMeta::CellLine {
+ cell_index: 1,
+ line_in_cell: 0
+ },
+ TranscriptLineMeta::CellLine {
+ cell_index: 1,
+ line_in_cell: 1
+ },
+ ]
+ );
+ assert_eq!(
+ cache.wrapped.transcript.joiner_before.as_slice(),
+ &[
+ None,
+ Some(" ".to_string()),
+ None,
+ None,
+ Some(" ".to_string()),
+ ]
+ );
+ }
+
+ #[test]
+ fn wrapped_cache_ensure_rebuilds_on_width_change() {
+ let calls0 = Arc::new(AtomicUsize::new(0));
+ let calls1 = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![
+ Arc::new(FakeCell::new(
+ vec![Line::from("• hello world")],
+ vec![None],
+ false,
+ calls0.clone(),
+ )),
+ Arc::new(FakeCell::new(
+ vec![Line::from("• foo bar")],
+ vec![None],
+ false,
+ calls1.clone(),
+ )),
+ ];
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&cells, 8);
+ cache.ensure_wrapped(&cells, 10);
+
+ assert_eq!(calls0.load(Ordering::Relaxed), 2);
+ assert_eq!(calls1.load(Ordering::Relaxed), 2);
+
+ let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells, 10);
+ assert_eq!(cache.lines(), expected.lines.as_slice());
+ assert_eq!(cache.line_meta(), expected.meta.as_slice());
+ assert_eq!(
+ cache.wrapped.transcript.joiner_before,
+ expected.joiner_before
+ );
+ }
+
+ #[test]
+ fn wrapped_cache_ensure_rebuilds_on_truncation() {
+ let calls0 = Arc::new(AtomicUsize::new(0));
+ let calls1 = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![
+ Arc::new(FakeCell::new(
+ vec![Line::from("• hello world")],
+ vec![None],
+ false,
+ calls0.clone(),
+ )),
+ Arc::new(FakeCell::new(
+ vec![Line::from("• foo bar")],
+ vec![None],
+ false,
+ calls1.clone(),
+ )),
+ ];
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&cells, 8);
+ cache.ensure_wrapped(&cells[..1], 8);
+
+ // The second ensure is a rebuild of the truncated prefix; only the first cell is rendered.
+ assert_eq!(calls0.load(Ordering::Relaxed), 2);
+ assert_eq!(calls1.load(Ordering::Relaxed), 1);
+
+ let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells[..1], 8);
+ assert_eq!(cache.lines(), expected.lines.as_slice());
+ assert_eq!(cache.line_meta(), expected.meta.as_slice());
+ }
+
+ #[test]
+ fn wrapped_cache_ensure_with_zero_width_clears_without_calling_cell_render() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![Arc::new(FakeCell::new(
+ vec![Line::from("• hello world")],
+ vec![None],
+ false,
+ calls.clone(),
+ ))];
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&cells, 0);
+
+ assert_eq!(calls.load(Ordering::Relaxed), 0);
+ assert_eq!(cache.lines(), &[]);
+ assert_eq!(cache.line_meta(), &[]);
+ assert_eq!(
+ cache.wrapped.transcript.joiner_before,
+ Vec::>::new()
+ );
+ }
+
+ #[test]
+ fn wrapped_cache_ensure_rebuilds_when_first_cell_pointer_changes() {
+ let calls_a = Arc::new(AtomicUsize::new(0));
+ let calls_b = Arc::new(AtomicUsize::new(0));
+
+ let cell_a0: Arc = Arc::new(FakeCell::new(
+ vec![Line::from("• a")],
+ vec![None],
+ false,
+ calls_a.clone(),
+ ));
+ let cell_a1: Arc = Arc::new(FakeCell::new(
+ vec![Line::from("• b")],
+ vec![None],
+ false,
+ calls_b.clone(),
+ ));
+
+ let mut cache = TranscriptViewCache::new();
+ cache.ensure_wrapped(&[cell_a0.clone(), cell_a1.clone()], 10);
+ assert_eq!(calls_a.load(Ordering::Relaxed), 1);
+ assert_eq!(calls_b.load(Ordering::Relaxed), 1);
+
+ // Replace the transcript with a different first cell but keep the length the same.
+ let calls_c = Arc::new(AtomicUsize::new(0));
+ let cell_b0: Arc = Arc::new(FakeCell::new(
+ vec![Line::from("• c")],
+ vec![None],
+ false,
+ calls_c.clone(),
+ ));
+
+ cache.ensure_wrapped(&[cell_b0.clone(), cell_a1.clone()], 10);
+
+ // This should be treated as a replacement and rebuilt from scratch.
+ assert_eq!(calls_c.load(Ordering::Relaxed), 1);
+ assert_eq!(calls_b.load(Ordering::Relaxed), 2);
+ }
+
+ #[test]
+ fn raster_cache_reuses_rows_and_clears_on_width_change() {
+ let mut cache = TranscriptViewCache::new();
+ let calls = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![Arc::new(FakeCell::new(
+ vec![Line::from(vec![
+ Span::from("• hello").style(Style::default().fg(Color::Magenta)),
+ ])],
+ vec![None],
+ false,
+ calls,
+ ))];
+
+ cache.ensure_wrapped(&cells, 20);
+ cache.set_raster_capacity(8);
+
+ let area = Rect::new(0, 0, 10, 1);
+ let mut buf = Buffer::empty(area);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.rows.len(), 1);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.rows.len(), 1);
+
+ let mut buf_wide = Buffer::empty(Rect::new(0, 0, 12, 1));
+ cache.render_row_index_into(0, Rect::new(0, 0, 12, 1), &mut buf_wide);
+ assert_eq!(cache.raster.width, 12);
+ assert_eq!(cache.raster.rows.len(), 1);
+ }
+
+ fn direct_render_cells(
+ line: &Line<'static>,
+ width: u16,
+ is_user_row: bool,
+ ) -> Vec {
+ let area = Rect::new(0, 0, width, 1);
+ let mut scratch = Buffer::empty(area);
+ if is_user_row {
+ let base_style = crate::style::user_message_style();
+ for x in 0..width {
+ scratch[(x, 0)].set_style(base_style);
+ }
+ }
+ line.render_ref(area, &mut scratch);
+ (0..width).map(|x| scratch[(x, 0)].clone()).collect()
+ }
+
+ #[test]
+ fn rasterize_line_matches_direct_render_for_user_and_non_user_rows() {
+ let width = 12;
+ let line = Line::from(vec!["hello".into(), " ".into(), "world".magenta()]);
+
+ let non_user = rasterize_line(&line, width, false);
+ assert_eq!(non_user, direct_render_cells(&line, width, false));
+
+ let user = rasterize_line(&line, width, true);
+ assert_eq!(user, direct_render_cells(&line, width, true));
+ }
+
+ #[test]
+ fn raster_cache_evicts_old_rows_when_over_capacity() {
+ let mut cache = TranscriptViewCache::new();
+ let calls = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![Arc::new(FakeCell::new(
+ vec![Line::from("first"), Line::from("second")],
+ vec![None, None],
+ false,
+ calls,
+ ))];
+
+ cache.ensure_wrapped(&cells, 10);
+ cache.set_raster_capacity(1);
+
+ let area = Rect::new(0, 0, 10, 1);
+ let mut buf = Buffer::empty(area);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.rows.len(), 1);
+ assert!(cache.raster.rows.contains_key(&raster_key(0, false)));
+
+ cache.render_row_index_into(1, area, &mut buf);
+ assert_eq!(cache.raster.rows.len(), 1);
+ assert!(cache.raster.rows.contains_key(&raster_key(1, false)));
+ }
+
+ #[test]
+ fn raster_cache_resets_when_palette_version_changes() {
+ let mut cache = TranscriptViewCache::new();
+ let calls = Arc::new(AtomicUsize::new(0));
+ let cells: Vec> = vec![Arc::new(FakeCell::new(
+ vec![Line::from("palette")],
+ vec![None],
+ false,
+ calls,
+ ))];
+
+ cache.ensure_wrapped(&cells, 20);
+ cache.set_raster_capacity(1);
+
+ let area = Rect::new(0, 0, 10, 1);
+ let mut buf = Buffer::empty(area);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.clock, 1);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.clock, 2);
+
+ crate::terminal_palette::requery_default_colors();
+ cache.render_row_index_into(0, area, &mut buf);
+ assert_eq!(cache.raster.clock, 1);
+ }
+
+ #[test]
+ fn render_row_index_into_treats_user_history_cells_as_user_rows() {
+ let mut cache = TranscriptViewCache::new();
+ let cells: Vec> = vec![Arc::new(UserHistoryCell {
+ message: "hello".to_string(),
+ })];
+
+ cache.ensure_wrapped(&cells, 20);
+ cache.set_raster_capacity(8);
+
+ let area = Rect::new(0, 0, 20, 1);
+ let mut buf = Buffer::empty(area);
+
+ cache.render_row_index_into(0, area, &mut buf);
+ assert!(cache.is_user_row(0));
+ assert!(cache.raster.rows.contains_key(&raster_key(0, true)));
+ }
+}
diff --git a/codex-rs/tui2/src/tui/scrolling.rs b/codex-rs/tui2/src/tui/scrolling.rs
index c3ca6e94de..4b7b23c150 100644
--- a/codex-rs/tui2/src/tui/scrolling.rs
+++ b/codex-rs/tui2/src/tui/scrolling.rs
@@ -17,7 +17,7 @@
//! the newly flattened line list on the next frame.
//!
//! Spacer rows between non-continuation cells are represented as `TranscriptLineMeta::Spacer`.
-//! They are not valid anchors; `anchor_for` will pick the nearest non-spacer line when needed.
+//! They are valid scroll anchors so 1-line scrolling does not "stick" at cell boundaries.
pub(crate) mod mouse;
pub(crate) use mouse::MouseScrollState;
@@ -78,6 +78,13 @@ pub(crate) enum TranscriptScroll {
cell_index: usize,
line_in_cell: usize,
},
+ /// Anchor the viewport to the spacer row immediately before a cell.
+ ///
+ /// This exists because spacer rows are real, visible transcript rows, and users may scroll
+ /// through them one line at a time (especially with trackpads). Without a dedicated spacer
+ /// anchor, a 1-line scroll that lands on a spacer would snap back to the adjacent cell line
+ /// and appear to "stick" at boundaries.
+ ScrolledSpacerBeforeCell { cell_index: usize },
}
impl TranscriptScroll {
@@ -108,6 +115,13 @@ impl TranscriptScroll {
None => (Self::ToBottom, max_start),
}
}
+ Self::ScrolledSpacerBeforeCell { cell_index } => {
+ let anchor = spacer_before_cell_index(line_meta, cell_index);
+ match anchor {
+ Some(idx) => (self, idx.min(max_start)),
+ None => (Self::ToBottom, max_start),
+ }
+ }
}
}
@@ -142,6 +156,11 @@ impl TranscriptScroll {
} => anchor_index(line_meta, cell_index, line_in_cell)
.unwrap_or(max_start)
.min(max_start),
+ Self::ScrolledSpacerBeforeCell { cell_index } => {
+ spacer_before_cell_index(line_meta, cell_index)
+ .unwrap_or(max_start)
+ .min(max_start)
+ }
};
let new_top = if delta_lines < 0 {
@@ -164,15 +183,35 @@ impl TranscriptScroll {
/// This is the inverse of "resolving a scroll state to a top-row offset":
/// given a concrete flattened line index, pick a stable `(cell_index, line_in_cell)` anchor.
///
- /// See `resolve_top` for `line_meta` semantics. This prefers the nearest line at or after `start`
- /// (skipping spacer rows), falling back to the nearest line before it when needed.
+ /// See `resolve_top` for `line_meta` semantics. This prefers the line at `start` (including
+ /// spacer rows), falling back to the nearest non-spacer line after or before it when needed.
pub(crate) fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option {
- let anchor =
- anchor_at_or_after(line_meta, start).or_else(|| anchor_at_or_before(line_meta, start));
- anchor.map(|(cell_index, line_in_cell)| Self::Scrolled {
- cell_index,
- line_in_cell,
- })
+ if line_meta.is_empty() {
+ return None;
+ }
+
+ let start = start.min(line_meta.len().saturating_sub(1));
+ match line_meta[start] {
+ TranscriptLineMeta::CellLine {
+ cell_index,
+ line_in_cell,
+ } => Some(Self::Scrolled {
+ cell_index,
+ line_in_cell,
+ }),
+ TranscriptLineMeta::Spacer => {
+ if let Some((cell_index, _)) = anchor_at_or_after(line_meta, start) {
+ Some(Self::ScrolledSpacerBeforeCell { cell_index })
+ } else {
+ anchor_at_or_before(line_meta, start).map(|(cell_index, line_in_cell)| {
+ Self::Scrolled {
+ cell_index,
+ line_in_cell,
+ }
+ })
+ }
+ }
+ }
}
}
@@ -198,6 +237,26 @@ fn anchor_index(
})
}
+/// Locate the flattened line index for the spacer row immediately before `cell_index`.
+///
+/// The spacer itself is not uniquely tagged in `TranscriptLineMeta`, so we locate the first
+/// visual line of the cell (`line_in_cell == 0`) and, if it is preceded by a spacer row, return
+/// that spacer's index. If the spacer is missing (for example when the cell is a stream
+/// continuation), we fall back to the cell's first line index so scrolling remains usable.
+fn spacer_before_cell_index(line_meta: &[TranscriptLineMeta], cell_index: usize) -> Option {
+ let cell_first = anchor_index(line_meta, cell_index, 0)?;
+ if cell_first > 0
+ && matches!(
+ line_meta.get(cell_first.saturating_sub(1)),
+ Some(TranscriptLineMeta::Spacer)
+ )
+ {
+ Some(cell_first.saturating_sub(1))
+ } else {
+ Some(cell_first)
+ }
+}
+
/// Find the first transcript line at or after the given flattened index.
fn anchor_at_or_after(line_meta: &[TranscriptLineMeta], start: usize) -> Option<(usize, usize)> {
if line_meta.is_empty() {
@@ -272,6 +331,33 @@ mod tests {
assert_eq!(top, 2);
}
+ #[test]
+ fn scrolled_by_can_land_on_spacer_rows() {
+ let meta = meta(&[
+ cell_line(0, 0),
+ TranscriptLineMeta::Spacer,
+ cell_line(1, 0),
+ cell_line(1, 1),
+ ]);
+
+ let scroll = TranscriptScroll::Scrolled {
+ cell_index: 1,
+ line_in_cell: 0,
+ };
+
+ assert_eq!(
+ scroll.scrolled_by(-1, &meta, 2),
+ TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 }
+ );
+ assert_eq!(
+ TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 }.scrolled_by(-1, &meta, 2),
+ TranscriptScroll::Scrolled {
+ cell_index: 0,
+ line_in_cell: 0
+ }
+ );
+ }
+
#[test]
fn resolve_top_scrolled_falls_back_when_anchor_missing() {
let meta = meta(&[cell_line(0, 0), TranscriptLineMeta::Spacer, cell_line(1, 0)]);
@@ -350,17 +436,11 @@ mod tests {
assert_eq!(
TranscriptScroll::anchor_for(&meta, 0),
- Some(TranscriptScroll::Scrolled {
- cell_index: 0,
- line_in_cell: 0
- })
+ Some(TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 0 })
);
assert_eq!(
TranscriptScroll::anchor_for(&meta, 2),
- Some(TranscriptScroll::Scrolled {
- cell_index: 1,
- line_in_cell: 0
- })
+ Some(TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 })
);
assert_eq!(
TranscriptScroll::anchor_for(&meta, 3),
diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml
index eec3925ffa..aa872035bc 100644
--- a/codex-rs/windows-sandbox-rs/Cargo.toml
+++ b/codex-rs/windows-sandbox-rs/Cargo.toml
@@ -47,7 +47,7 @@ version = "0.8"
[dependencies.dirs-next]
version = "2.0"
-[dependencies.windows-sys]
+[target.'cfg(windows)'.dependencies.windows-sys]
features = [
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs
new file mode 100644
index 0000000000..41298b8fba
--- /dev/null
+++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs
@@ -0,0 +1,306 @@
+#![cfg(target_os = "windows")]
+
+use anyhow::Result;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use base64::Engine;
+use rand::rngs::SmallRng;
+use rand::RngCore;
+use rand::SeedableRng;
+use serde::Serialize;
+use std::ffi::c_void;
+use std::ffi::OsStr;
+use std::fs::File;
+use std::path::Path;
+use std::path::PathBuf;
+use windows_sys::Win32::Foundation::GetLastError;
+use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
+use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success;
+use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd;
+use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAddMembers;
+use windows_sys::Win32::NetworkManagement::NetManagement::NetUserAdd;
+use windows_sys::Win32::NetworkManagement::NetManagement::NetUserSetInfo;
+use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_INFO_1;
+use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3;
+use windows_sys::Win32::NetworkManagement::NetManagement::UF_DONT_EXPIRE_PASSWD;
+use windows_sys::Win32::NetworkManagement::NetManagement::UF_SCRIPT;
+use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1;
+use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003;
+use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER;
+use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW;
+use windows_sys::Win32::Security::LookupAccountNameW;
+use windows_sys::Win32::Security::SID_NAME_USE;
+
+use codex_windows_sandbox::dpapi_protect;
+use codex_windows_sandbox::sandbox_dir;
+use codex_windows_sandbox::string_from_sid_bytes;
+use codex_windows_sandbox::to_wide;
+use codex_windows_sandbox::SETUP_VERSION;
+
+pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers";
+const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)";
+
+pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> {
+ ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log)
+}
+
+pub fn resolve_sandbox_users_group_sid() -> Result> {
+ resolve_sid(SANDBOX_USERS_GROUP)
+}
+
+pub fn provision_sandbox_users(
+ codex_home: &Path,
+ offline_username: &str,
+ online_username: &str,
+ log: &mut File,
+) -> Result<()> {
+ ensure_sandbox_users_group(log)?;
+ super::log_line(
+ log,
+ &format!("ensuring sandbox users offline={offline_username} online={online_username}"),
+ )?;
+ let offline_password = random_password();
+ let online_password = random_password();
+ ensure_sandbox_user(offline_username, &offline_password, log)?;
+ ensure_sandbox_user(online_username, &online_password, log)?;
+ write_secrets(
+ codex_home,
+ offline_username,
+ &offline_password,
+ online_username,
+ &online_password,
+ )?;
+ Ok(())
+}
+
+pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut File) -> Result<()> {
+ ensure_local_user(username, password, log)?;
+ ensure_local_group_member(SANDBOX_USERS_GROUP, username)?;
+ Ok(())
+}
+
+pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> {
+ let name_w = to_wide(OsStr::new(name));
+ let pwd_w = to_wide(OsStr::new(password));
+ unsafe {
+ let info = USER_INFO_1 {
+ usri1_name: name_w.as_ptr() as *mut u16,
+ usri1_password: pwd_w.as_ptr() as *mut u16,
+ usri1_password_age: 0,
+ usri1_priv: USER_PRIV_USER,
+ usri1_home_dir: std::ptr::null_mut(),
+ usri1_comment: std::ptr::null_mut(),
+ usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD,
+ usri1_script_path: std::ptr::null_mut(),
+ };
+ let status = NetUserAdd(
+ std::ptr::null(),
+ 1,
+ &info as *const _ as *mut u8,
+ std::ptr::null_mut(),
+ );
+ if status != NERR_Success {
+ // Try update password via level 1003.
+ let pw_info = USER_INFO_1003 {
+ usri1003_password: pwd_w.as_ptr() as *mut u16,
+ };
+ let upd = NetUserSetInfo(
+ std::ptr::null(),
+ name_w.as_ptr(),
+ 1003,
+ &pw_info as *const _ as *mut u8,
+ std::ptr::null_mut(),
+ );
+ if upd != NERR_Success {
+ super::log_line(log, &format!("NetUserSetInfo failed for {name} code {upd}"))?;
+ return Err(anyhow::anyhow!(
+ "failed to create/update user {name}, code {status}/{upd}"
+ ));
+ }
+ }
+
+ // Ensure the principal is a regular local user account.
+ let group = to_wide(OsStr::new("Users"));
+ let member = LOCALGROUP_MEMBERS_INFO_3 {
+ lgrmi3_domainandname: name_w.as_ptr() as *mut u16,
+ };
+ let _ = NetLocalGroupAddMembers(
+ std::ptr::null(),
+ group.as_ptr(),
+ 3,
+ &member as *const _ as *mut u8,
+ 1,
+ );
+ }
+ Ok(())
+}
+
+pub fn ensure_local_group(name: &str, comment: &str, log: &mut File) -> Result<()> {
+ const ERROR_ALIAS_EXISTS: u32 = 1379;
+ const NERR_GROUP_EXISTS: u32 = 2223;
+
+ let name_w = to_wide(OsStr::new(name));
+ let comment_w = to_wide(OsStr::new(comment));
+ unsafe {
+ let info = LOCALGROUP_INFO_1 {
+ lgrpi1_name: name_w.as_ptr() as *mut u16,
+ lgrpi1_comment: comment_w.as_ptr() as *mut u16,
+ };
+ let mut parm_err: u32 = 0;
+ let status = NetLocalGroupAdd(
+ std::ptr::null(),
+ 1,
+ &info as *const _ as *mut u8,
+ &mut parm_err as *mut _,
+ );
+ if status != NERR_Success && status != ERROR_ALIAS_EXISTS && status != NERR_GROUP_EXISTS {
+ super::log_line(
+ log,
+ &format!("NetLocalGroupAdd failed for {name} code {status} parm_err={parm_err}"),
+ )?;
+ anyhow::bail!("failed to create local group {name}, code {status}");
+ }
+ }
+ Ok(())
+}
+
+pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result<()> {
+ // If the member is already in the group, NetLocalGroupAddMembers may
+ // return an error code. We don't care.
+ let group_w = to_wide(OsStr::new(group_name));
+ let member_w = to_wide(OsStr::new(member_name));
+ unsafe {
+ let member = LOCALGROUP_MEMBERS_INFO_3 {
+ lgrmi3_domainandname: member_w.as_ptr() as *mut u16,
+ };
+ let _ = NetLocalGroupAddMembers(
+ std::ptr::null(),
+ group_w.as_ptr(),
+ 3,
+ &member as *const _ as *mut u8,
+ 1,
+ );
+ }
+ Ok(())
+}
+
+pub fn resolve_sid(name: &str) -> Result> {
+ let name_w = to_wide(OsStr::new(name));
+ let mut sid_buffer = vec![0u8; 68];
+ let mut sid_len: u32 = sid_buffer.len() as u32;
+ let mut domain: Vec = Vec::new();
+ let mut domain_len: u32 = 0;
+ let mut use_type: SID_NAME_USE = 0;
+ loop {
+ let ok = unsafe {
+ LookupAccountNameW(
+ std::ptr::null(),
+ name_w.as_ptr(),
+ sid_buffer.as_mut_ptr() as *mut c_void,
+ &mut sid_len,
+ domain.as_mut_ptr(),
+ &mut domain_len,
+ &mut use_type,
+ )
+ };
+ if ok != 0 {
+ sid_buffer.truncate(sid_len as usize);
+ return Ok(sid_buffer);
+ }
+ let err = unsafe { GetLastError() };
+ if err == ERROR_INSUFFICIENT_BUFFER {
+ sid_buffer.resize(sid_len as usize, 0);
+ domain.resize(domain_len as usize, 0);
+ continue;
+ }
+ return Err(anyhow::anyhow!(
+ "LookupAccountNameW failed for {name}: {err}"
+ ));
+ }
+}
+
+pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> {
+ let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?;
+ let sid_w = to_wide(OsStr::new(&sid_str));
+ let mut psid: *mut c_void = std::ptr::null_mut();
+ if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 {
+ return Err(anyhow::anyhow!(
+ "ConvertStringSidToSidW failed: {}",
+ unsafe { GetLastError() }
+ ));
+ }
+ Ok(psid)
+}
+
+fn random_password() -> String {
+ const CHARS: &[u8] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+";
+ let mut rng = SmallRng::from_entropy();
+ let mut buf = [0u8; 24];
+ rng.fill_bytes(&mut buf);
+ buf.iter()
+ .map(|b| {
+ let idx = (*b as usize) % CHARS.len();
+ CHARS[idx] as char
+ })
+ .collect()
+}
+
+#[derive(Serialize)]
+struct SandboxUserRecord {
+ username: String,
+ password: String,
+}
+
+#[derive(Serialize)]
+struct SandboxUsersFile {
+ version: u32,
+ offline: SandboxUserRecord,
+ online: SandboxUserRecord,
+}
+
+#[derive(Serialize)]
+struct SetupMarker {
+ version: u32,
+ offline_username: String,
+ online_username: String,
+ created_at: String,
+ read_roots: Vec,
+ write_roots: Vec,
+}
+
+fn write_secrets(
+ codex_home: &Path,
+ offline_user: &str,
+ offline_pwd: &str,
+ online_user: &str,
+ online_pwd: &str,
+) -> Result<()> {
+ let sandbox_dir = sandbox_dir(codex_home);
+ std::fs::create_dir_all(&sandbox_dir)?;
+ let offline_blob = dpapi_protect(offline_pwd.as_bytes())?;
+ let online_blob = dpapi_protect(online_pwd.as_bytes())?;
+ let users = SandboxUsersFile {
+ version: SETUP_VERSION,
+ offline: SandboxUserRecord {
+ username: offline_user.to_string(),
+ password: BASE64.encode(offline_blob),
+ },
+ online: SandboxUserRecord {
+ username: online_user.to_string(),
+ password: BASE64.encode(online_blob),
+ },
+ };
+ let marker = SetupMarker {
+ version: SETUP_VERSION,
+ offline_username: offline_user.to_string(),
+ online_username: online_user.to_string(),
+ created_at: chrono::Utc::now().to_rfc3339(),
+ read_roots: Vec::new(),
+ write_roots: Vec::new(),
+ };
+ let users_path = sandbox_dir.join("sandbox_users.json");
+ let marker_path = sandbox_dir.join("setup_marker.json");
+ std::fs::write(users_path, serde_json::to_vec_pretty(&users)?)?;
+ std::fs::write(marker_path, serde_json::to_vec_pretty(&marker)?)?;
+ Ok(())
+}
diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs
index 02db1ffc90..4796cda5da 100644
--- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs
+++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs
@@ -5,7 +5,6 @@ use anyhow::Result;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use codex_windows_sandbox::convert_string_sid_to_sid;
-use codex_windows_sandbox::dpapi_protect;
use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance;
use codex_windows_sandbox::ensure_allow_write_aces;
use codex_windows_sandbox::load_or_create_cap_sids;
@@ -13,11 +12,9 @@ use codex_windows_sandbox::log_note;
use codex_windows_sandbox::path_mask_allows;
use codex_windows_sandbox::sandbox_dir;
use codex_windows_sandbox::string_from_sid_bytes;
+use codex_windows_sandbox::to_wide;
use codex_windows_sandbox::LOG_FILE_NAME;
use codex_windows_sandbox::SETUP_VERSION;
-use rand::rngs::SmallRng;
-use rand::RngCore;
-use rand::SeedableRng;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
@@ -25,7 +22,6 @@ use std::ffi::c_void;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Write;
-use std::os::windows::ffi::OsStrExt;
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::path::PathBuf;
@@ -50,18 +46,7 @@ use windows::Win32::System::Com::CLSCTX_INPROC_SERVER;
use windows::Win32::System::Com::COINIT_APARTMENTTHREADED;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Foundation::LocalFree;
-use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
use windows_sys::Win32::Foundation::HLOCAL;
-use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success;
-use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAddMembers;
-use windows_sys::Win32::NetworkManagement::NetManagement::NetUserAdd;
-use windows_sys::Win32::NetworkManagement::NetManagement::NetUserSetInfo;
-use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3;
-use windows_sys::Win32::NetworkManagement::NetManagement::UF_DONT_EXPIRE_PASSWD;
-use windows_sys::Win32::NetworkManagement::NetManagement::UF_SCRIPT;
-use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1;
-use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003;
-use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER;
use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW;
use windows_sys::Win32::Security::Authorization::SetEntriesInAclW;
use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW;
@@ -70,12 +55,10 @@ use windows_sys::Win32::Security::Authorization::GRANT_ACCESS;
use windows_sys::Win32::Security::Authorization::SE_FILE_OBJECT;
use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID;
use windows_sys::Win32::Security::Authorization::TRUSTEE_W;
-use windows_sys::Win32::Security::LookupAccountNameW;
use windows_sys::Win32::Security::ACL;
use windows_sys::Win32::Security::CONTAINER_INHERIT_ACE;
use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION;
use windows_sys::Win32::Security::OBJECT_INHERIT_ACE;
-use windows_sys::Win32::Security::SID_NAME_USE;
use windows_sys::Win32::Storage::FileSystem::DELETE;
use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD;
use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE;
@@ -83,8 +66,13 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ;
use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE;
mod read_acl_mutex;
+mod sandbox_users;
use read_acl_mutex::acquire_read_acl_mutex;
use read_acl_mutex::read_acl_mutex_exists;
+use sandbox_users::provision_sandbox_users;
+use sandbox_users::resolve_sandbox_users_group_sid;
+use sandbox_users::resolve_sid;
+use sandbox_users::sid_bytes_to_psid;
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Payload {
@@ -114,158 +102,12 @@ impl Default for SetupMode {
}
}
-#[derive(Serialize)]
-struct SandboxUserRecord {
- username: String,
- password: String,
-}
-
-#[derive(Serialize)]
-struct SandboxUsersFile {
- version: u32,
- offline: SandboxUserRecord,
- online: SandboxUserRecord,
-}
-
-#[derive(Serialize)]
-struct SetupMarker {
- version: u32,
- offline_username: String,
- online_username: String,
- created_at: String,
- read_roots: Vec,
- write_roots: Vec,
-}
-
fn log_line(log: &mut File, msg: &str) -> Result<()> {
let ts = chrono::Utc::now().to_rfc3339();
writeln!(log, "[{ts}] {msg}")?;
Ok(())
}
-fn to_wide(s: &OsStr) -> Vec {
- let mut v: Vec = s.encode_wide().collect();
- v.push(0);
- v
-}
-
-fn random_password() -> String {
- const CHARS: &[u8] =
- b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+";
- let mut rng = SmallRng::from_entropy();
- let mut buf = [0u8; 24];
- rng.fill_bytes(&mut buf);
- buf.iter()
- .map(|b| {
- let idx = (*b as usize) % CHARS.len();
- CHARS[idx] as char
- })
- .collect()
-}
-
-fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> {
- let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?;
- let sid_w = to_wide(OsStr::new(&sid_str));
- let mut psid: *mut c_void = std::ptr::null_mut();
- if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 {
- return Err(anyhow::anyhow!(
- "ConvertStringSidToSidW failed: {}",
- unsafe { GetLastError() }
- ));
- }
- Ok(psid)
-}
-
-fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> {
- let name_w = to_wide(OsStr::new(name));
- let pwd_w = to_wide(OsStr::new(password));
- unsafe {
- let info = USER_INFO_1 {
- usri1_name: name_w.as_ptr() as *mut u16,
- usri1_password: pwd_w.as_ptr() as *mut u16,
- usri1_password_age: 0,
- usri1_priv: USER_PRIV_USER,
- usri1_home_dir: std::ptr::null_mut(),
- usri1_comment: std::ptr::null_mut(),
- usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD,
- usri1_script_path: std::ptr::null_mut(),
- };
- let status = NetUserAdd(
- std::ptr::null(),
- 1,
- &info as *const _ as *mut u8,
- std::ptr::null_mut(),
- );
- if status != NERR_Success {
- // Try update password via level 1003.
- let pw_info = USER_INFO_1003 {
- usri1003_password: pwd_w.as_ptr() as *mut u16,
- };
- let upd = NetUserSetInfo(
- std::ptr::null(),
- name_w.as_ptr(),
- 1003,
- &pw_info as *const _ as *mut u8,
- std::ptr::null_mut(),
- );
- if upd != NERR_Success {
- log_line(log, &format!("NetUserSetInfo failed for {name} code {upd}"))?;
- return Err(anyhow::anyhow!(
- "failed to create/update user {name}, code {status}/{upd}"
- ));
- }
- }
- let group = to_wide(OsStr::new("Users"));
- let member = LOCALGROUP_MEMBERS_INFO_3 {
- lgrmi3_domainandname: name_w.as_ptr() as *mut u16,
- };
- let _ = NetLocalGroupAddMembers(
- std::ptr::null(),
- group.as_ptr(),
- 3,
- &member as *const _ as *mut u8,
- 1,
- );
- }
- Ok(())
-}
-
-fn resolve_sid(name: &str) -> Result> {
- let name_w = to_wide(OsStr::new(name));
- let mut sid_buffer = vec![0u8; 68];
- let mut sid_len: u32 = sid_buffer.len() as u32;
- let mut domain: Vec = Vec::new();
- let mut domain_len: u32 = 0;
- let mut use_type: SID_NAME_USE = 0;
- loop {
- let ok = unsafe {
- LookupAccountNameW(
- std::ptr::null(),
- name_w.as_ptr(),
- sid_buffer.as_mut_ptr() as *mut c_void,
- &mut sid_len,
- domain.as_mut_ptr(),
- &mut domain_len,
- &mut use_type,
- )
- };
- if ok != 0 {
- sid_buffer.truncate(sid_len as usize);
- return Ok(sid_buffer);
- }
- let err = unsafe { GetLastError() };
- if err == ERROR_INSUFFICIENT_BUFFER {
- sid_buffer.resize(sid_len as usize, 0);
- domain.resize(domain_len as usize, 0);
- continue;
- }
- return Err(anyhow::anyhow!(
- "LookupAccountNameW failed for {name}: {}",
- err
- ));
- }
-}
-
fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> {
let mut read_payload = payload.clone();
read_payload.mode = SetupMode::ReadAclsOnly;
@@ -285,8 +127,7 @@ fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> {
}
struct ReadAclSubjects<'a> {
- offline_psid: *mut c_void,
- online_psid: *mut c_void,
+ sandbox_group_psid: *mut c_void,
rx_psids: &'a [*mut c_void],
}
@@ -319,25 +160,16 @@ fn apply_read_acls(
if builtin_has {
continue;
}
- let offline_has = read_mask_allows_or_log(
+ let sandbox_has = read_mask_allows_or_log(
root,
- &[subjects.offline_psid],
- Some("offline"),
+ &[subjects.sandbox_group_psid],
+ Some("sandbox_group"),
access_mask,
access_label,
refresh_errors,
log,
)?;
- let online_has = read_mask_allows_or_log(
- root,
- &[subjects.online_psid],
- Some("online"),
- access_mask,
- access_label,
- refresh_errors,
- log,
- )?;
- if offline_has && online_has {
+ if sandbox_has {
continue;
}
log_line(
@@ -347,55 +179,23 @@ fn apply_read_acls(
root.display()
),
)?;
- let mut successes = usize::from(offline_has) + usize::from(online_has);
- let mut missing_psids: Vec<*mut c_void> = Vec::new();
- let mut missing_labels: Vec<&str> = Vec::new();
- if !offline_has {
- missing_psids.push(subjects.offline_psid);
- missing_labels.push("offline");
- }
- if !online_has {
- missing_psids.push(subjects.online_psid);
- missing_labels.push("online");
- }
- if !missing_psids.is_empty() {
- let result = unsafe {
- ensure_allow_mask_aces_with_inheritance(
- root,
- &missing_psids,
- access_mask,
- inheritance,
- )
- };
- match result {
- Ok(_) => {
- successes = 2;
- }
- Err(err) => {
- let label_list = missing_labels.join(", ");
- for label in &missing_labels {
- refresh_errors.push(format!(
- "grant {access_label} ACE failed on {} for {label}: {err}",
- root.display()
- ));
- }
- log_line(
- log,
- &format!(
- "grant {access_label} ACE failed on {} for {}: {err}",
- root.display(),
- label_list
- ),
- )?;
- }
- }
- }
- if successes == 2 {
- } else {
+ let result = unsafe {
+ ensure_allow_mask_aces_with_inheritance(
+ root,
+ &[subjects.sandbox_group_psid],
+ access_mask,
+ inheritance,
+ )
+ };
+ if let Err(err) = result {
+ refresh_errors.push(format!(
+ "grant {access_label} ACE failed on {} for sandbox_group: {err}",
+ root.display()
+ ));
log_line(
log,
&format!(
- "{access_label} ACE incomplete on {} (success {successes}/2)",
+ "grant {access_label} ACE failed on {} for sandbox_group: {err}",
root.display()
),
)?;
@@ -515,7 +315,7 @@ fn run_netsh_firewall(sid: &str, log: &mut File) -> Result<()> {
fn lock_sandbox_dir(
dir: &Path,
real_user: &str,
- sandbox_user_sids: &[Vec],
+ sandbox_group_sid: &[u8],
_log: &mut File,
) -> Result<()> {
std::fs::create_dir_all(dir)?;
@@ -535,24 +335,15 @@ fn lock_sandbox_dir(
real_sid,
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE,
),
+ (
+ sandbox_group_sid.to_vec(),
+ FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE,
+ ),
];
- let sandbox_entries: Vec<(Vec, u32)> = sandbox_user_sids
- .iter()
- .map(|sid| {
- (
- sid.clone(),
- FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE,
- )
- })
- .collect();
unsafe {
let mut eas: Vec = Vec::new();
let mut sids: Vec<*mut c_void> = Vec::new();
- for (sid_bytes, mask) in entries
- .iter()
- .map(|(s, m)| (s, *m))
- .chain(sandbox_entries.iter().map(|(s, m)| (s, *m)))
- {
+ for (sid_bytes, mask) in entries.iter().map(|(s, m)| (s, *m)) {
let sid_str = string_from_sid_bytes(sid_bytes).map_err(anyhow::Error::msg)?;
let sid_w = to_wide(OsStr::new(&sid_str));
let mut psid: *mut c_void = std::ptr::null_mut();
@@ -617,45 +408,6 @@ fn lock_sandbox_dir(
Ok(())
}
-fn write_secrets(
- codex_home: &Path,
- offline_user: &str,
- offline_pwd: &str,
- online_user: &str,
- online_pwd: &str,
- _read_roots: &[PathBuf],
- _write_roots: &[PathBuf],
-) -> Result<()> {
- let sandbox_dir = sandbox_dir(codex_home);
- std::fs::create_dir_all(&sandbox_dir)?;
- let offline_blob = dpapi_protect(offline_pwd.as_bytes())?;
- let online_blob = dpapi_protect(online_pwd.as_bytes())?;
- let users = SandboxUsersFile {
- version: SETUP_VERSION,
- offline: SandboxUserRecord {
- username: offline_user.to_string(),
- password: BASE64.encode(offline_blob),
- },
- online: SandboxUserRecord {
- username: online_user.to_string(),
- password: BASE64.encode(online_blob),
- },
- };
- let marker = SetupMarker {
- version: SETUP_VERSION,
- offline_username: offline_user.to_string(),
- online_username: online_user.to_string(),
- created_at: chrono::Utc::now().to_rfc3339(),
- read_roots: Vec::new(),
- write_roots: Vec::new(),
- };
- let users_path = sandbox_dir.join("sandbox_users.json");
- let marker_path = sandbox_dir.join("setup_marker.json");
- std::fs::write(users_path, serde_json::to_vec_pretty(&users)?)?;
- std::fs::write(marker_path, serde_json::to_vec_pretty(&marker)?)?;
- Ok(())
-}
-
pub fn main() -> Result<()> {
let ret = real_main();
if let Err(e) = &ret {
@@ -723,10 +475,8 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
}
};
log_line(log, "read-acl-only mode: applying read ACLs")?;
- let offline_sid = resolve_sid(&payload.offline_username)?;
- let online_sid = resolve_sid(&payload.online_username)?;
- let offline_psid = sid_bytes_to_psid(&offline_sid)?;
- let online_psid = sid_bytes_to_psid(&online_sid)?;
+ let sandbox_group_sid = resolve_sandbox_users_group_sid()?;
+ let sandbox_group_psid = sid_bytes_to_psid(&sandbox_group_sid)?;
let mut refresh_errors: Vec = Vec::new();
let users_sid = resolve_sid("Users")?;
let users_psid = sid_bytes_to_psid(&users_sid)?;
@@ -736,8 +486,7 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
let everyone_psid = sid_bytes_to_psid(&everyone_sid)?;
let rx_psids = vec![users_psid, auth_psid, everyone_psid];
let subjects = ReadAclSubjects {
- offline_psid,
- online_psid,
+ sandbox_group_psid,
rx_psids: &rx_psids,
};
apply_read_acls(
@@ -750,11 +499,8 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
)?;
unsafe {
- if !offline_psid.is_null() {
- LocalFree(offline_psid as HLOCAL);
- }
- if !online_psid.is_null() {
- LocalFree(online_psid as HLOCAL);
+ if !sandbox_group_psid.is_null() {
+ LocalFree(sandbox_group_psid as HLOCAL);
}
if !users_psid.is_null() {
LocalFree(users_psid as HLOCAL);
@@ -781,38 +527,21 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> {
let refresh_only = payload.refresh_only;
- let offline_pwd = if refresh_only {
- None
- } else {
- Some(random_password())
- };
- let online_pwd = if refresh_only {
- None
- } else {
- Some(random_password())
- };
if refresh_only {
} else {
- log_line(
- log,
- &format!(
- "ensuring sandbox users offline={} online={}",
- payload.offline_username, payload.online_username
- ),
- )?;
- ensure_local_user(
+ provision_sandbox_users(
+ &payload.codex_home,
&payload.offline_username,
- offline_pwd.as_ref().unwrap(),
+ &payload.online_username,
log,
)?;
- ensure_local_user(&payload.online_username, online_pwd.as_ref().unwrap(), log)?;
}
let offline_sid = resolve_sid(&payload.offline_username)?;
- let online_sid = resolve_sid(&payload.online_username)?;
- let offline_psid = sid_bytes_to_psid(&offline_sid)?;
- let online_psid = sid_bytes_to_psid(&online_sid)?;
let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?;
+ let sandbox_group_sid = resolve_sandbox_users_group_sid()?;
+ let sandbox_group_psid = sid_bytes_to_psid(&sandbox_group_sid)?;
+
let caps = load_or_create_cap_sids(&payload.codex_home)?;
let cap_psid = unsafe {
convert_string_sid_to_sid(&caps.workspace)
@@ -844,8 +573,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
}
let cap_sid_str = caps.workspace.clone();
- let online_sid_str = string_from_sid_bytes(&online_sid).map_err(anyhow::Error::msg)?;
- let sid_strings = vec![offline_sid_str.clone(), online_sid_str, cap_sid_str];
+ let sandbox_group_sid_str =
+ string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?;
+ let sid_strings = vec![sandbox_group_sid_str, cap_sid_str];
let write_mask =
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD;
let mut grant_tasks: Vec = Vec::new();
@@ -864,11 +594,7 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
continue;
}
let mut need_grant = false;
- for (label, psid) in [
- ("offline", offline_psid),
- ("online", online_psid),
- ("cap", cap_psid),
- ] {
+ for (label, psid) in [("sandbox_group", sandbox_group_psid), ("cap", cap_psid)] {
let has = match path_mask_allows(root, &[psid], write_mask, true) {
Ok(h) => h,
Err(e) => {
@@ -896,7 +622,7 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
log_line(
log,
&format!(
- "granting write ACE to {} for sandbox users and capability SID",
+ "granting write ACE to {} for sandbox group and capability SID",
root.display()
),
)?;
@@ -964,25 +690,13 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
lock_sandbox_dir(
&sandbox_dir(&payload.codex_home),
&payload.real_user,
- &[offline_sid.clone(), online_sid.clone()],
+ &sandbox_group_sid,
log,
)?;
- write_secrets(
- &payload.codex_home,
- &payload.offline_username,
- offline_pwd.as_ref().unwrap(),
- &payload.online_username,
- online_pwd.as_ref().unwrap(),
- &payload.read_roots,
- &payload.write_roots,
- )?;
}
unsafe {
- if !offline_psid.is_null() {
- LocalFree(offline_psid as HLOCAL);
- }
- if !online_psid.is_null() {
- LocalFree(online_psid as HLOCAL);
+ if !sandbox_group_psid.is_null() {
+ LocalFree(sandbox_group_psid as HLOCAL);
}
if !cap_psid.is_null() {
LocalFree(cap_psid as HLOCAL);
diff --git a/docs/advanced.md b/docs/advanced.md
deleted file mode 100644
index 26ffca8a92..0000000000
--- a/docs/advanced.md
+++ /dev/null
@@ -1,74 +0,0 @@
-## Advanced
-
-If you already lean on Codex every day and just need a little more control, this page collects the knobs you are most likely to reach for: tweak defaults in [Config](./config.md), add extra tools through [Model Context Protocol support](#model-context-protocol), and script full runs with [`codex exec`](./exec.md). Jump to the section you need and keep building.
-
-## Config quickstart
-
-Most day-to-day tuning lives in `config.toml`: set approval + sandbox presets, pin model defaults, and add MCP server launchers. The [Config guide](./config.md) walks through every option and provides copy-paste examples for common setups.
-
-## Tracing / verbose logging
-
-Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior.
-
-The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written:
-
-```bash
-tail -F ~/.codex/log/codex-tui.log
-```
-
-By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file.
-
-See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options.
-
-## Model Context Protocol (MCP)
-
-The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#mcp-integration).
-
-## Using Codex as an MCP Server
-
-The Codex CLI can also be run as an MCP _server_ via `codex mcp-server`. For example, you can use `codex mcp-server` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). Use `codex mcp` separately to add/list/get/remove MCP server launchers in your configuration.
-
-### Codex MCP Server Quickstart
-
-You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector):
-
-```bash
-npx @modelcontextprotocol/inspector codex mcp-server
-```
-
-Send a `tools/list` request and you will see that there are two tools available:
-
-**`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties:
-
-| Property | Type | Description |
-| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. |
-| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`. |
-| `base-instructions` | string | The set of instructions to use instead of the default ones. |
-| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. |
-| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. |
-| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). |
-| `profile` | string | Configuration profile from `config.toml` to specify default options. |
-| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. |
-
-**`codex-reply`** - Continue a Codex session by providing the conversation id and prompt. The `codex-reply` tool takes the following properties:
-
-| Property | Type | Description |
-| ------------------------------- | ------ | -------------------------------------------------------- |
-| **`prompt`** (required) | string | The next user prompt to continue the Codex conversation. |
-| **`conversationId`** (required) | string | The id of the conversation to continue. |
-
-### Trying it Out
-
-> [!TIP]
-> Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration.
-
-Use the MCP inspector and `codex mcp-server` to build a simple tic-tac-toe game with the following settings:
-
-**approval-policy:** never
-
-**prompt:** Implement a simple tic-tac-toe game with HTML, JavaScript, and CSS. Write the game in a single file called index.html.
-
-**sandbox:** workspace-write
-
-Click "Run Tool" and you should see a list of events emitted from the Codex MCP server as it builds the game.
diff --git a/docs/agents_md.md b/docs/agents_md.md
index ff2243a0ca..4fa02abd1d 100644
--- a/docs/agents_md.md
+++ b/docs/agents_md.md
@@ -1,50 +1,3 @@
-# AGENTS.md Discovery
+# AGENTS.md
-Codex uses [`AGENTS.md`](https://agents.md/) files to gather helpful guidance before it starts assisting you. This page explains how those files are discovered and combined, so you can decide where to place your instructions.
-
-## Global Instructions (`~/.codex`)
-
-- Codex looks for global guidance in your Codex home directory (usually `~/.codex`; set `CODEX_HOME` to change it). For a quick overview, see the [Memory with AGENTS.md section](../docs/getting-started.md#memory-with-agentsmd) in the getting started guide.
-- If an `AGENTS.override.md` file exists there, it takes priority. If not, Codex falls back to `AGENTS.md`.
-- Only the first non-empty file is used. Other filenames, such as `instructions.md`, have no effect unless Codex is specifically instructed to use them.
-- Whatever Codex finds here stays active for the whole session, and Codex combines it with any project-specific instructions it discovers.
-
-## Project Instructions (per-repository)
-
-When you work inside a project, Codex builds on those global instructions by collecting project docs:
-
-- The search starts at the repository root and continues down to your current directory. If a Git root is not found, only the current directory is checked.
-- In each directory along that path, Codex looks for `AGENTS.override.md` first, then `AGENTS.md`, and then any fallback names listed in your Codex configuration (see [`project_doc_fallback_filenames`](../docs/config.md#project_doc_fallback_filenames)). At most one file per directory is included.
-- Files are read in order from root to leaf and joined together with blank lines. Empty files are skipped, and very large files are truncated once the combined size reaches 32 KiB (the default [`project_doc_max_bytes`](../docs/config.md#project_doc_max_bytes) limit). If you need more space, split guidance across nested directories or raise the limit in your configuration.
-
-## How They Come Together
-
-Before Codex gets to work, the instructions are ingested in precedence order: global guidance from `~/.codex` comes first, then each project doc from the repository root down to your current directory. Guidance in deeper directories overrides earlier layers, so the most specific file controls the final behavior.
-
-### Priority Summary
-
-1. Global `AGENTS.override.md` (if present), otherwise global `AGENTS.md`.
-2. For each directory from the repository root to your working directory: `AGENTS.override.md`, then `AGENTS.md`, then configured fallback names.
-
-Only these filenames are considered. To use a different name, add it to the fallback list in your Codex configuration or rename the file accordingly.
-
-## Fallback Filenames
-
-Codex can look for additional instruction filenames beyond the two defaults if you add them to `project_doc_fallback_filenames` in your Codex configuration. Each fallback is checked after `AGENTS.override.md` and `AGENTS.md` in every directory along the search path.
-
-Example: suppose your configuration lists `["TEAM_GUIDE.md", ".agents.md"]`. Inside each directory Codex will look in this order:
-
-1. `AGENTS.override.md`
-2. `AGENTS.md`
-3. `TEAM_GUIDE.md`
-4. `.agents.md`
-
-If the repository root contains `TEAM_GUIDE.md` and the `backend/` directory contains `AGENTS.override.md`, the overall instructions will combine the root `TEAM_GUIDE.md` (because no override or default file was present there) with the `backend/AGENTS.override.md` file (which takes precedence over the fallback names).
-
-You can configure those fallbacks in `~/.codex/config.toml` (or another profile) like this:
-
-```toml
-project_doc_fallback_filenames = ["TEAM_GUIDE.md", ".agents.md"]
-```
-
-For additional configuration details, see [Config](../docs/config.md) and revisit the [Memory with AGENTS.md guide](../docs/getting-started.md#memory-with-agentsmd) for practical usage tips.
+For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md).
diff --git a/docs/authentication.md b/docs/authentication.md
index 617161f648..c307349766 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -1,68 +1,3 @@
# Authentication
-## Usage-based billing alternative: Use an OpenAI API key
-
-If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API key:
-
-```shell
-printenv OPENAI_API_KEY | codex login --with-api-key
-```
-
-Alternatively, read from a file:
-
-```shell
-codex login --with-api-key < my_key.txt
-```
-
-The legacy `--api-key` flag now exits with an error instructing you to use `--with-api-key` so that the key never appears in shell history or process listings.
-
-This key must, at minimum, have write access to the Responses API.
-
-## Migrating to ChatGPT login from API key
-
-If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps:
-
-1. Update the CLI and ensure `codex --version` is `0.20.0` or later
-2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`)
-3. Run `codex login` again
-
-## Connecting on a "Headless" Machine
-
-Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds:
-
-### Authenticate locally and copy your credentials to the "headless" machine
-
-The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\\.codex`).
-
-Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do:
-
-```shell
-# substitute MY_CONTAINER with the name or id of your Docker container:
-CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME)
-docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex"
-docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json"
-```
-
-whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol):
-
-```shell
-ssh user@remote 'mkdir -p ~/.codex'
-scp ~/.codex/auth.json user@remote:~/.codex/auth.json
-```
-
-or try this one-liner:
-
-```shell
-ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json
-```
-
-### Connecting through VPS or remote
-
-If you run Codex on a remote machine (VPS/server) without a local browser, the login helper starts a server on `localhost:1455` on the remote host. To complete login in your local browser, forward that port to your machine before starting the login flow:
-
-```bash
-# From your local machine
-ssh -L 1455:localhost:1455 @
-```
-
-Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server.
+For information about Codex CLI authentication, see [this documentation](https://developers.openai.com/codex/auth).
diff --git a/docs/config.md b/docs/config.md
index 8452a831e2..2b64253d30 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,1071 +1,19 @@
-# Config
+# Configuration
-Codex configuration gives you fine-grained control over the model, execution environment, and integrations available to the CLI. Use this guide alongside the workflows in [`codex exec`](./exec.md), the guardrails in [Sandbox & approvals](./sandbox.md), and project guidance from [AGENTS.md discovery](./agents_md.md).
+For basic configuration instructions, see [this documentation](https://developers.openai.com/codex/config-basic).
-## Quick navigation
+For advanced configuration instructions, see [this documentation](https://developers.openai.com/codex/config-advanced).
-- [Feature flags](#feature-flags)
-- [Model selection](#model-selection)
-- [Execution environment](#execution-environment)
-- [Project root detection](#project-root-detection)
-- [MCP integration](#mcp-integration)
-- [Observability and telemetry](#observability-and-telemetry)
-- [Profiles and overrides](#profiles-and-overrides)
-- [Reference table](#config-reference)
+For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference).
-Codex supports several mechanisms for setting config values:
+## Connecting to MCP servers
-- Config-specific command-line flags, such as `--model o3` (highest precedence).
-- A generic `-c`/`--config` flag that takes a `key=value` pair, such as `--config model="o3"`.
- - The key can contain dots to set a value deeper than the root, e.g. `--config model_providers.openai.wire_api="chat"`.
- - For consistency with `config.toml`, values are a string in TOML format rather than JSON format, so use `key='{a = 1, b = 2}'` rather than `key='{"a": 1, "b": 2}'`.
- - The quotes around the value are necessary, as without them your shell would split the config argument on spaces, resulting in `codex` receiving `-c key={a` with (invalid) additional arguments `=`, `1,`, `b`, `=`, `2}`.
- - Values can contain any TOML object, such as `--config shell_environment_policy.include_only='["PATH", "HOME", "USER"]'`.
- - If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that `-c model='"o3"'` and `-c model=o3` are equivalent.
- - In the first case, the value is the TOML string `"o3"`, while in the second the value is `o3`, which is not valid TOML and therefore treated as the TOML string `"o3"`.
- - Because quotes are interpreted by one's shell, `-c key="true"` will be correctly interpreted in TOML as `key = true` (a boolean) and not `key = "true"` (a string). If for some reason you needed the string `"true"`, you would need to use `-c key='"true"'` (note the two sets of quotes).
-- The `$CODEX_HOME/config.toml` configuration file where the `CODEX_HOME` environment value defaults to `~/.codex`. (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.)
+Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the configuration reference for the latest MCP server options:
-Both the `--config` flag and the `config.toml` file support the following options:
+- https://developers.openai.com/codex/config-reference
-## Feature flags
+## Notify
-Optional and experimental capabilities are toggled via the `[features]` table in `$CODEX_HOME/config.toml`. If you see a deprecation notice mentioning a legacy key (for example `experimental_use_exec_command_tool`), move the setting into `[features]` or pass `--enable `.
+Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings:
-```toml
-[features]
-web_search_request = true # allow the model to request web searches
-# view_image_tool defaults to true; omit to keep defaults
-```
-
-Supported features:
-
-| Key | Default | Stage | Description |
-| ------------------------------------- | :-----: | ------------ | ----------------------------------------------------- |
-| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool |
-| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool |
-| `view_image_tool` | true | Stable | Include the `view_image` tool |
-| `web_search_request` | false | Stable | Allow the model to issue web searches |
-| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox |
-| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation |
-| `skills` | false | Experimental | Enable discovery and injection of skills |
-
-Notes:
-
-- Omit a key to accept its default.
-- Legacy booleans such as `experimental_use_exec_command_tool`, `experimental_use_unified_exec_tool`, `include_apply_patch_tool`, and similar `experimental_use_*` keys are deprecated; setting the corresponding `[features].` avoids repeated warnings.
-
-## Model selection
-
-### model
-
-The model that Codex should use.
-
-```toml
-model = "gpt-5.1" # overrides the default ("gpt-5.1-codex-max" across platforms)
-```
-
-### model_providers
-
-This option lets you add to the default set of model providers bundled with Codex. The map key becomes the value you use with `model_provider` to select the provider.
-
-> [!NOTE]
-> Built-in providers are not overwritten when you reuse their key. Entries you add only take effect when the key is **new**; for example `[model_providers.openai]` leaves the original OpenAI definition untouched. To customize the bundled OpenAI provider, prefer the dedicated knobs (for example the `OPENAI_BASE_URL` environment variable) or register a new provider key and point `model_provider` at it.
-
-For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration:
-
-```toml
-# Recall that in TOML, root keys must be listed before tables.
-model = "gpt-4o"
-model_provider = "openai-chat-completions"
-
-[model_providers.openai-chat-completions]
-# Name of the provider that will be displayed in the Codex UI.
-name = "OpenAI using Chat Completions"
-# The path `/chat/completions` will be amended to this URL to make the POST
-# request for the chat completions.
-base_url = "https://api.openai.com/v1"
-# If `env_key` is set, identifies an environment variable that must be set when
-# using Codex with this provider. The value of the environment variable must be
-# non-empty and will be used in the `Bearer TOKEN` HTTP header for the POST request.
-env_key = "OPENAI_API_KEY"
-# Valid values for wire_api are "chat" and "responses". Defaults to "chat" if omitted.
-wire_api = "chat"
-# If necessary, extra query params that need to be added to the URL.
-# See the Azure example below.
-query_params = {}
-```
-
-Note this makes it possible to use Codex CLI with non-OpenAI models, so long as they use a wire API that is compatible with the OpenAI chat completions API. For example, you could define the following provider to use Codex CLI with Ollama running locally:
-
-```toml
-[model_providers.ollama]
-name = "Ollama"
-base_url = "http://localhost:11434/v1"
-```
-
-Or a third-party provider (using a distinct environment variable for the API key):
-
-```toml
-[model_providers.mistral]
-name = "Mistral"
-base_url = "https://api.mistral.ai/v1"
-env_key = "MISTRAL_API_KEY"
-```
-
-It is also possible to configure a provider to include extra HTTP headers with a request. These can be hardcoded values (`http_headers`) or values read from environment variables (`env_http_headers`):
-
-```toml
-[model_providers.example]
-# name, base_url, ...
-
-# This will add the HTTP header `X-Example-Header` with value `example-value`
-# to each request to the model provider.
-http_headers = { "X-Example-Header" = "example-value" }
-
-# This will add the HTTP header `X-Example-Features` with the value of the
-# `EXAMPLE_FEATURES` environment variable to each request to the model provider
-# _if_ the environment variable is set and its value is non-empty.
-env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" }
-```
-
-#### Azure model provider example
-
-Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider:
-
-```toml
-[model_providers.azure]
-name = "Azure"
-# Make sure you set the appropriate subdomain for this URL.
-base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
-env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use.
-query_params = { api-version = "2025-04-01-preview" }
-wire_api = "responses"
-```
-
-Export your key before launching Codex: `export AZURE_OPENAI_API_KEY=…`
-
-#### Per-provider network tuning
-
-The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.)
-
-Example:
-
-```toml
-[model_providers.openai]
-name = "OpenAI"
-base_url = "https://api.openai.com/v1"
-env_key = "OPENAI_API_KEY"
-# network tuning overrides (all optional; falls back to built‑in defaults)
-request_max_retries = 4 # retry failed HTTP requests
-stream_max_retries = 10 # retry dropped SSE streams
-stream_idle_timeout_ms = 300000 # 5m idle timeout
-```
-
-##### request_max_retries
-
-How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`.
-
-##### stream_max_retries
-
-Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `5`.
-
-##### stream_idle_timeout_ms
-
-How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes).
-
-### model_provider
-
-Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable.
-
-Note that if you override `model_provider`, then you likely want to override
-`model`, as well. For example, if you are running ollama with Mistral locally,
-then you would need to add the following to your config in addition to the new entry in the `model_providers` map:
-
-```toml
-model_provider = "ollama"
-model = "mistral"
-```
-
-### model_reasoning_effort
-
-If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5.1-codex-max`, `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.2`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to:
-
-- `"minimal"`
-- `"low"`
-- `"medium"` (default)
-- `"high"`
-- `"xhigh"` (available on `gpt-5.1-codex-max` and `gpt-5.2`)
-
-Note: to minimize reasoning, choose `"minimal"`.
-
-### model_reasoning_summary
-
-If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to:
-
-- `"auto"` (default)
-- `"concise"`
-- `"detailed"`
-
-To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config:
-
-```toml
-model_reasoning_summary = "none" # disable reasoning summaries
-```
-
-### model_verbosity
-
-Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values:
-
-- `"low"`
-- `"medium"` (default when omitted)
-- `"high"`
-
-When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`.
-
-Example:
-
-```toml
-model = "gpt-5.1"
-model_verbosity = "low"
-```
-
-Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected.
-
-### model_supports_reasoning_summaries
-
-By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`:
-
-```toml
-model_supports_reasoning_summaries = true
-```
-
-### model_context_window
-
-The size of the context window for the model, in tokens.
-
-In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation.
-
-### oss_provider
-
-Specifies the default OSS provider to use when running Codex. This is used when the `--oss` flag is provided without a specific provider.
-
-Valid values are:
-
-- `"lmstudio"` - Use LM Studio as the local model provider
-- `"ollama"` - Use Ollama as the local model provider
-
-```toml
-# Example: Set default OSS provider to LM Studio
-oss_provider = "lmstudio"
-```
-
-## Execution environment
-
-### approval_policy
-
-Determines when the user should be prompted to approve whether Codex can execute a command:
-
-```toml
-# Codex has hardcoded logic that defines a set of "trusted" commands.
-# Setting the approval_policy to `untrusted` means that Codex will prompt the
-# user before running a command not in the "trusted" set.
-#
-# See https://github.com/openai/codex/issues/1260 for the plan to enable
-# end-users to define their own trusted commands.
-approval_policy = "untrusted"
-```
-
-If you want to be notified whenever a command fails, use "on-failure":
-
-```toml
-# If the command fails when run in the sandbox, Codex asks for permission to
-# retry the command outside the sandbox.
-approval_policy = "on-failure"
-```
-
-If you want the model to run until it decides that it needs to ask you for escalated permissions, use "on-request":
-
-```toml
-# The model decides when to escalate
-approval_policy = "on-request"
-```
-
-Alternatively, you can have the model run until it is done, and never ask to run a command with escalated permissions:
-
-```toml
-# User is never prompted: if the command fails, Codex will automatically try
-# something out. Note the `exec` subcommand always uses this mode.
-approval_policy = "never"
-```
-
-### sandbox_mode
-
-Codex executes model-generated shell commands inside an OS-level sandbox.
-
-In most cases you can pick the desired behaviour with a single option:
-
-```toml
-# same as `--sandbox read-only`
-sandbox_mode = "read-only"
-```
-
-The default policy is `read-only`, which means commands can read any file on
-disk, but attempts to write a file or access the network will be blocked.
-
-A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using the directory where it was spawned as `cwd`, though this can be overridden using `--cwd/-C`.
-
-On macOS (and soon Linux), all writable roots (including `cwd`) that contain a `.git/` or `.codex/` folder _as an immediate child_ will configure those folders to be read-only while the rest of the root stays writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission.
-
-```toml
-# same as `--sandbox workspace-write`
-sandbox_mode = "workspace-write"
-
-# Extra settings that only apply when `sandbox = "workspace-write"`.
-[sandbox_workspace_write]
-# By default, the cwd for the Codex session will be writable as well as $TMPDIR
-# (if set) and /tmp (if it exists). Setting the respective options to `true`
-# will override those defaults.
-exclude_tmpdir_env_var = false
-exclude_slash_tmp = false
-
-# Optional list of _additional_ writable roots beyond $TMPDIR and /tmp.
-writable_roots = ["/Users/YOU/.pyenv/shims"]
-
-# Allow the command being run inside the sandbox to make outbound network
-# requests. Disabled by default.
-network_access = false
-```
-
-To disable sandboxing altogether, specify `danger-full-access` like so:
-
-```toml
-# same as `--sandbox danger-full-access`
-sandbox_mode = "danger-full-access"
-```
-
-This is reasonable to use if Codex is running in an environment that provides its own sandboxing (such as a Docker container) such that further sandboxing is unnecessary.
-
-Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows.
-
-### tools.\*
-
-These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)).
-
-Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default:
-
-```toml
-[tools]
-web_search = true # allow Codex to issue first-party web searches without prompting you (deprecated)
-view_image = false # disable image uploads (they're enabled by default)
-```
-
-The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
-
-### approval_presets
-
-Codex provides three main Approval Presets:
-
-- Read Only: Codex can read files and answer questions; edits, running commands, and network access require approval.
-- Auto: Codex can read files, make edits, and run commands in the workspace without approval; asks for approval outside the workspace or for network access.
-- Full Access: Full disk and network access without prompts; extremely risky.
-
-You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options.
-
-> See also [Sandbox & approvals](./sandbox.md) for in-depth examples and platform-specific behaviour.
-
-### shell_environment_policy
-
-Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`:
-
-```toml
-[shell_environment_policy]
-# inherit can be "all" (default), "core", or "none"
-inherit = "core"
-# set to true to *skip* the filter for `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"`
-ignore_default_excludes = true
-# exclude patterns (case-insensitive globs)
-exclude = ["AWS_*", "AZURE_*"]
-# force-set / override values
-set = { CI = "1" }
-# if provided, *only* vars matching these patterns are kept
-include_only = ["PATH", "HOME"]
-```
-
-| Field | Type | Default | Description |
-| ------------------------- | -------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
-| `inherit` | string | `all` | Starting template for the environment: `all` (clone full parent env), `core` (`HOME`, `PATH`, `USER`, …), or `none` (start empty). |
-| `ignore_default_excludes` | boolean | `true` | When `false`, Codex removes any var whose **name** contains `KEY`, `SECRET`, or `TOKEN` (case-insensitive) before other rules run. |
-| `exclude` | array | `[]` | Case-insensitive glob patterns to drop after the default filter. Examples: `"AWS_*"`, `"AZURE_*"`. |
-| `set` | table | `{}` | Explicit key/value overrides or additions – always win over inherited values. |
-| `include_only` | array | `[]` | If non-empty, a whitelist of patterns; only variables that match _one_ pattern survive the final step. (Generally used with `inherit = "all"`.) |
-
-The patterns are **glob style**, not full regular expressions: `*` matches any
-number of characters, `?` matches exactly one, and character classes like
-`[A-Z]`/`[^0-9]` are supported. Matching is always **case-insensitive**. This
-syntax is documented in code as `EnvironmentVariablePattern` (see
-`core/src/config_types.rs`).
-
-If you just need a clean slate with a few custom entries you can write:
-
-```toml
-[shell_environment_policy]
-inherit = "none"
-set = { PATH = "/usr/bin", MY_FLAG = "1" }
-```
-
-Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable.
-
-## Project root detection
-
-Codex discovers `.codex/` project layers by walking up from the working directory until it hits a project marker. By default it looks for `.git`. You can override the marker list in user/system/MDM config:
-
-```toml
-# $CODEX_HOME/config.toml
-project_root_markers = [".git", ".hg", ".sl"]
-```
-
-Set `project_root_markers = []` to skip searching parent directories and treat the current working directory as the project root.
-
-## MCP integration
-
-### mcp_servers
-
-You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services.
-
-#### Server configuration
-
-##### STDIO
-
-[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer.
-
-```toml
-# The top-level table name must be `mcp_servers`
-# The sub-table name (`server-name` in this example) can be anything you would like.
-[mcp_servers.server_name]
-command = "npx"
-# Optional
-args = ["-y", "mcp-server"]
-# Optional: propagate additional env vars to the MCP server.
-# A default whitelist of env vars will be propagated to the MCP server.
-# https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82
-env = { "API_KEY" = "value" }
-# or
-[mcp_servers.server_name.env]
-API_KEY = "value"
-# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment.
-env_vars = ["API_KEY2"]
-
-# Optional: cwd that the command will be run from
-cwd = "/Users//code/my-server"
-```
-
-##### Streamable HTTP
-
-[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain).
-
-```toml
-[mcp_servers.figma]
-url = "https://mcp.figma.com/mcp"
-# Optional environment variable containing a bearer token to use for auth
-bearer_token_env_var = "ENV_VAR"
-# Optional map of headers with hard-coded values.
-http_headers = { "HEADER_NAME" = "HEADER_VALUE" }
-# Optional map of headers whose values will be replaced with the environment variable.
-env_http_headers = { "HEADER_NAME" = "ENV_VAR" }
-```
-
-Streamable HTTP connections always use the Rust MCP client under the hood. Run `codex mcp login ` to authenticate for servers supporting OAuth.
-
-#### Other configuration options
-
-```toml
-# Optional: override the default 10s startup timeout
-startup_timeout_sec = 20
-# Optional: override the default 60s per-tool timeout
-tool_timeout_sec = 30
-# Optional: disable a server without removing it
-enabled = false
-# Optional: only expose a subset of tools from this server
-enabled_tools = ["search", "summarize"]
-# Optional: hide specific tools (applied after `enabled_tools`, if set)
-disabled_tools = ["search"]
-```
-
-When both `enabled_tools` and `disabled_tools` are specified, Codex first restricts the server to the allow-list and then removes any tools that appear in the deny-list.
-
-#### MCP CLI commands
-
-```shell
-# List all available commands
-codex mcp --help
-
-# Add a server (env can be repeated; `--` separates the launcher command)
-codex mcp add docs -- docs-server --port 4000
-
-# List configured servers (pretty table or JSON)
-codex mcp list
-codex mcp list --json
-
-# Show one server (table or JSON)
-codex mcp get docs
-codex mcp get docs --json
-
-# Remove a server
-codex mcp remove docs
-
-# Log in to a streamable HTTP server that supports oauth
-codex mcp login SERVER_NAME
-
-# Log out from a streamable HTTP server that supports oauth
-codex mcp logout SERVER_NAME
-```
-
-### Examples of useful MCPs
-
-There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex.
-
-Some of the most common MCPs we've seen are:
-
-- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation
-- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs
-- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright
-- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser
-- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs
-- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.)
-
-## Observability and telemetry
-
-### otel
-
-Codex can emit [OpenTelemetry](https://opentelemetry.io/) **log events** that
-describe each run: outbound API requests, streamed responses, user input,
-tool-approval decisions, and the result of every tool invocation. Export is
-**disabled by default** so local runs remain self-contained. Opt in by adding an
-`[otel]` table and choosing an exporter.
-
-```toml
-[otel]
-environment = "staging" # defaults to "dev"
-exporter = "none" # defaults to "none"; set to otlp-http or otlp-grpc to send events
-log_user_prompt = false # defaults to false; redact prompt text unless explicitly enabled
-```
-
-Codex tags every exported event with `service.name = $ORIGINATOR` (the same
-value sent in the `originator` header, `codex_cli_rs` by default), the CLI
-version, and an `env` attribute so downstream collectors can distinguish
-dev/staging/prod traffic. Only telemetry produced inside the `codex_otel`
-crate—the events listed below—is forwarded to the exporter.
-
-### Event catalog
-
-Every event shares a common set of metadata fields: `event.timestamp`,
-`conversation.id`, `app.version`, `auth_mode` (when available),
-`user.account_id` (when available), `user.email` (when available), `terminal.type`, `model`, and `slug`.
-
-With OTEL enabled Codex emits the following event types (in addition to the
-metadata above):
-
-- `codex.conversation_starts`
- - `provider_name`
- - `reasoning_effort` (optional)
- - `reasoning_summary`
- - `context_window` (optional)
- - `max_output_tokens` (optional)
- - `auto_compact_token_limit` (optional)
- - `approval_policy`
- - `sandbox_policy`
- - `mcp_servers` (comma-separated list)
- - `active_profile` (optional)
-- `codex.api_request`
- - `attempt`
- - `duration_ms`
- - `http.response.status_code` (optional)
- - `error.message` (failures)
-- `codex.sse_event`
- - `event.kind`
- - `duration_ms`
- - `error.message` (failures)
- - `input_token_count` (responses only)
- - `output_token_count` (responses only)
- - `cached_token_count` (responses only, optional)
- - `reasoning_token_count` (responses only, optional)
- - `tool_token_count` (responses only)
-- `codex.user_prompt`
- - `prompt_length`
- - `prompt` (redacted unless `log_user_prompt = true`)
-- `codex.tool_decision`
- - `tool_name`
- - `call_id`
- - `decision` (`approved`, `approved_execpolicy_amendment`, `approved_for_session`, `denied`, or `abort`)
- - `source` (`config` or `user`)
-- `codex.tool_result`
- - `tool_name`
- - `call_id` (optional)
- - `arguments` (optional)
- - `duration_ms` (execution time for the tool)
- - `success` (`"true"` or `"false"`)
- - `output`
-
-These event shapes may change as we iterate.
-
-### Choosing an exporter
-
-Set `otel.exporter` to control where events go:
-
-- `none` – leaves instrumentation active but skips exporting. This is the
- default.
-- `otlp-http` – posts OTLP log records to an OTLP/HTTP collector. Specify the
- endpoint, protocol, and headers your collector expects:
-
- ```toml
- [otel.exporter."otlp-http"]
- endpoint = "https://otel.example.com/v1/logs"
- protocol = "binary"
-
- [otel.exporter."otlp-http".headers]
- "x-otlp-api-key" = "${OTLP_TOKEN}"
- ```
-
-- `otlp-grpc` – streams OTLP log records over gRPC. Provide the endpoint and any
- metadata headers:
-
- ```toml
- [otel]
- exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}}
- ```
-
-Both OTLP exporters accept an optional `tls` block so you can trust a custom CA
-or enable mutual TLS. Relative paths are resolved against `~/.codex/`:
-
-```toml
-[otel.exporter."otlp-http"]
-endpoint = "https://otel.example.com/v1/logs"
-protocol = "binary"
-
-[otel.exporter."otlp-http".headers]
-"x-otlp-api-key" = "${OTLP_TOKEN}"
-
-[otel.exporter."otlp-http".tls]
-ca-certificate = "certs/otel-ca.pem"
-client-certificate = "/etc/codex/certs/client.pem"
-client-private-key = "/etc/codex/certs/client-key.pem"
-```
-
-If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your
-own collector. All exporters run on a background batch worker that is flushed on
-shutdown.
-
-If you build Codex from source the OTEL crate is still behind an `otel` feature
-flag; the official prebuilt binaries ship with the feature enabled. When the
-feature is disabled the telemetry hooks become no-ops so the CLI continues to
-function without the extra dependencies.
-
-### notify
-
-Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.:
-
-```json
-{
- "type": "agent-turn-complete",
- "thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
- "turn-id": "12345",
- "cwd": "/Users/alice/projects/example",
- "input-messages": ["Rename `foo` to `bar` and update the callsites."],
- "last-assistant-message": "Rename complete and verified `cargo build` succeeds."
-}
-```
-
-The `"type"` property will always be set. Currently, `"agent-turn-complete"` is the only notification type that is supported.
-
-`"thread-id"` contains a string that identifies the Codex session that produced the notification; you can use it to correlate multiple turns that belong to the same task.
-
-`"cwd"` reports the absolute working directory for the session so scripts can disambiguate which project triggered the notification.
-
-As an example, here is a Python script that parses the JSON and decides whether to show a desktop push notification using [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS:
-
-```python
-#!/usr/bin/env python3
-
-import json
-import subprocess
-import sys
-
-
-def main() -> int:
- if len(sys.argv) != 2:
- print("Usage: notify.py ")
- return 1
-
- try:
- notification = json.loads(sys.argv[1])
- except json.JSONDecodeError:
- return 1
-
- match notification_type := notification.get("type"):
- case "agent-turn-complete":
- assistant_message = notification.get("last-assistant-message")
- if assistant_message:
- title = f"Codex: {assistant_message}"
- else:
- title = "Codex: Turn Complete!"
- input_messages = notification.get("input-messages", [])
- message = " ".join(input_messages)
- title += message
- case _:
- print(f"not sending a push notification for: {notification_type}")
- return 0
-
- thread_id = notification.get("thread-id", "")
-
- subprocess.check_output(
- [
- "terminal-notifier",
- "-title",
- title,
- "-message",
- message,
- "-group",
- "codex-" + thread_id,
- "-ignoreDnD",
- "-activate",
- "com.googlecode.iterm2",
- ]
- )
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
-```
-
-To have Codex use this script for notifications, you would configure it via `notify` in `~/.codex/config.toml` using the appropriate path to `notify.py` on your computer:
-
-```toml
-notify = ["python3", "/Users/mbolin/.codex/notify.py"]
-```
-
-> [!NOTE]
-> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering.
-
-When Codex detects WSL 2 inside Windows Terminal (the session exports `WT_SESSION`), `tui.notifications` automatically switches to a Windows toast backend by spawning `powershell.exe`. This ensures both approval prompts and completed turns trigger native toasts even though Windows Terminal ignores OSC 9 escape sequences. Terminals that advertise OSC 9 support (iTerm2, WezTerm, kitty, etc.) continue to use the existing escape-sequence backend, and the `notify` hook remains unchanged.
-
-### hide_agent_reasoning
-
-Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output.
-
-Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command:
-
-```toml
-hide_agent_reasoning = true # defaults to false
-```
-
-### show_raw_agent_reasoning
-
-Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available.
-
-Notes:
-
-- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect.
-- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow.
-
-Example:
-
-```toml
-show_raw_agent_reasoning = true # defaults to false
-```
-
-## Profiles and overrides
-
-### profiles
-
-A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you
-want to use at runtime via the `--profile` flag.
-
-Here is an example of a `config.toml` that defines multiple profiles:
-
-```toml
-model = "o3"
-approval_policy = "untrusted"
-
-# Setting `profile` is equivalent to specifying `--profile o3` on the command
-# line, though the `--profile` flag can still be used to override this value.
-profile = "o3"
-
-[model_providers.openai-chat-completions]
-name = "OpenAI using Chat Completions"
-base_url = "https://api.openai.com/v1"
-env_key = "OPENAI_API_KEY"
-wire_api = "chat"
-
-[profiles.o3]
-model = "o3"
-model_provider = "openai"
-approval_policy = "never"
-model_reasoning_effort = "high"
-model_reasoning_summary = "detailed"
-
-[profiles.gpt3]
-model = "gpt-3.5-turbo"
-model_provider = "openai-chat-completions"
-
-[profiles.zdr]
-model = "o3"
-model_provider = "openai"
-approval_policy = "on-failure"
-```
-
-Users can specify config values at multiple levels. Order of precedence is as follows:
-
-1. custom command-line argument, e.g., `--model o3`
-2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
-3. as an entry in `config.toml`, e.g., `model = "o3"`
-4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5.1-codex-max`)
-
-### history
-
-By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner.
-
-To disable this behavior, configure `[history]` as follows:
-
-```toml
-[history]
-persistence = "none" # "save-all" is the default value
-```
-
-To cap the size of `history.jsonl`, set `history.max_bytes` to a positive byte
-count. When the file grows beyond the limit, Codex removes the oldest entries,
-compacting the file down to roughly 80% of the hard cap while keeping the newest
-record intact. Omitting the option—or setting it to `0`—disables pruning.
-
-### file_opener
-
-Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them.
-
-For example, if the model output includes a reference such as `【F:/home/user/project/main.py†L42-L50】`, then this would be rewritten to link to the URI `vscode://file/home/user/project/main.py:42`.
-
-Note this is **not** a general editor setting (like `$EDITOR`), as it only accepts a fixed set of values:
-
-- `"vscode"` (default)
-- `"vscode-insiders"`
-- `"windsurf"`
-- `"cursor"`
-- `"none"` to explicitly disable this feature
-
-Currently, `"vscode"` is the default, though Codex does not verify VS Code is installed. As such, `file_opener` may default to `"none"` or something else in the future.
-
-### project_doc_max_bytes
-
-Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
-
-### project_doc_fallback_filenames
-
-Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time.
-
-```toml
-project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"]
-```
-
-We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance.
-
-> See also [AGENTS.md discovery](./agents_md.md) for how Codex locates these files during a session.
-
-### tui
-
-Options that are specific to the TUI.
-
-```toml
-[tui]
-# Send desktop notifications when approvals are required or a turn completes.
-# Defaults to true.
-notifications = true
-
-# You can optionally filter to specific notification types.
-# Available types are "agent-turn-complete" and "approval-requested".
-notifications = [ "agent-turn-complete", "approval-requested" ]
-
-# Disable terminal animations (welcome screen, status shimmer, spinner).
-# Defaults to true.
-animations = false
-
-# TUI2 mouse scrolling (wheel + trackpad)
-#
-# Terminals emit different numbers of raw scroll events per physical wheel notch (commonly 1, 3,
-# or 9+). TUI2 normalizes raw event density into consistent wheel behavior (default: ~3 lines per
-# wheel notch) while keeping trackpad input higher fidelity via fractional accumulation.
-#
-# See `codex-rs/tui2/docs/scroll_input_model.md` for the model and probe data.
-
-# Override *wheel* event density (raw events per physical wheel notch). TUI2 only.
-#
-# Wheel-like per-event contribution is:
-# - `scroll_wheel_lines / scroll_events_per_tick`
-#
-# Trackpad-like streams use `min(scroll_events_per_tick, 3)` as the divisor so dense wheel ticks
-# (e.g. 9 events per notch) do not make trackpads feel artificially slow.
-scroll_events_per_tick = 3
-
-# Override wheel scroll lines per physical wheel notch (classic feel). TUI2 only.
-scroll_wheel_lines = 3
-
-# Override baseline trackpad sensitivity (lines per tick-equivalent). TUI2 only.
-#
-# Trackpad-like per-event contribution is:
-# - `scroll_trackpad_lines / min(scroll_events_per_tick, 3)`
-scroll_trackpad_lines = 1
-
-# Trackpad acceleration (optional). TUI2 only.
-# These keep small swipes precise while letting large/faster swipes cover more content.
-#
-# Concretely, TUI2 computes:
-# - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)`
-#
-# The multiplier is applied to the trackpad-like stream’s computed line delta (including any
-# carried fractional remainder).
-scroll_trackpad_accel_events = 30
-scroll_trackpad_accel_max = 3
-
-# Force scroll interpretation. TUI2 only.
-# Valid values: "auto" (default), "wheel", "trackpad"
-scroll_mode = "auto"
-
-# Auto-mode heuristic tuning. TUI2 only.
-scroll_wheel_tick_detect_max_ms = 12
-scroll_wheel_like_max_duration_ms = 200
-
-# Invert scroll direction for mouse wheel/trackpad. TUI2 only.
-scroll_invert = false
-```
-
-> [!NOTE]
-> Codex emits desktop notifications using terminal escape codes. Not all terminals support these (notably, macOS Terminal.app and VS Code's terminal do not support custom notifications. iTerm2, Ghostty and WezTerm do support these notifications).
-
-> [!NOTE] > `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together.
-
-Scroll settings (`tui.scroll_events_per_tick`, `tui.scroll_wheel_lines`, `tui.scroll_trackpad_lines`, `tui.scroll_trackpad_accel_*`, `tui.scroll_mode`, `tui.scroll_wheel_*`, `tui.scroll_invert`) currently apply to the TUI2 viewport scroll implementation.
-
-> [!NOTE] > `tui.scroll_events_per_tick` has terminal-specific defaults derived from mouse scroll probe logs
-> collected on macOS for a small set of terminals:
->
-> - Terminal.app: 3
-> - Warp: 9
-> - WezTerm: 1
-> - Alacritty: 3
-> - Ghostty: 3 (stopgap; one probe measured ~9)
-> - iTerm2: 1
-> - VS Code terminal: 1
-> - Kitty: 3
->
-> We should augment these defaults with data from more terminals and other platforms over time.
-> Unknown terminals fall back to 3 and can be overridden via `tui.scroll_events_per_tick`.
-
-## Authentication and authorization
-
-### Forcing a login method
-
-To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields:
-
-```toml
-# Force the user to log in with ChatGPT or via an api key.
-forced_login_method = "chatgpt" or "api"
-# When logging in with ChatGPT, only the specified workspace ID will be presented during the login
-# flow and the id will be validated during the oauth callback as well as every time Codex starts.
-forced_chatgpt_workspace_id = "00000000-0000-0000-0000-000000000000"
-```
-
-If the active credentials don't match the config, the user will be logged out and Codex will exit.
-
-If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work.
-
-### Control where login credentials are stored
-
-```toml
-cli_auth_credentials_store = "keyring"
-```
-
-Valid values:
-
-- `file` (default) – Store credentials in `auth.json` under `$CODEX_HOME`.
-- `keyring` – Store credentials in the operating system keyring via the [`keyring` crate](https://crates.io/crates/keyring); the CLI reports an error if secure storage is unavailable. Backends by OS:
- - macOS: macOS Keychain
- - Windows: Windows Credential Manager
- - Linux: DBus‑based Secret Service, the kernel keyutils, or a combination
- - FreeBSD/OpenBSD: DBus‑based Secret Service
-- `auto` – Save credentials to the operating system keyring when available; otherwise, fall back to `auth.json` under `$CODEX_HOME`.
-
-## Config reference
-
-| Key | Type / Values | Notes |
-| ------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
-| `model` | string | Model to use (e.g., `gpt-5.1-codex-max`). |
-| `model_provider` | string | Provider id from `model_providers` (default: `openai`). |
-| `model_context_window` | number | Context window tokens. |
-| `tool_output_token_limit` | number | Token budget for stored function/tool outputs in history (default: 2,560 tokens). |
-| `approval_policy` | `untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt for approval. |
-| `sandbox_mode` | `read-only` \| `workspace-write` \| `danger-full-access` | OS sandbox policy. |
-| `sandbox_workspace_write.writable_roots` | array | Extra writable roots in workspace‑write. |
-| `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). |
-| `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). |
-| `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). |
-| `notify` | array | External program for notifications. |
-| `tui.animations` | boolean | Enable terminal animations (welcome screen, shimmer, spinner). Defaults to true; set to `false` to disable visual motion. |
-| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. |
-| `developer_instructions` | string | The additional developer instructions. |
-| `features.` | boolean | See [feature flags](#feature-flags) for details |
-| `ghost_snapshot.disable_warnings` | boolean | Disable every warnings around ghost snapshot (large files, directory, ...) |
-| `ghost_snapshot.ignore_large_untracked_files` | number | Exclude untracked files larger than this many bytes from ghost snapshots (default: 10 MiB). Set to `0` to disable. |
-| `ghost_snapshot.ignore_large_untracked_dirs` | number | Ignore untracked directories with at least this many files (default: 200). Set to `0` to disable. |
-| `mcp_servers..command` | string | MCP server launcher command (stdio servers only). |
-| `mcp_servers..args` | array | MCP server args (stdio servers only). |
-| `mcp_servers..env` | map | MCP server env vars (stdio servers only). |
-| `mcp_servers..url` | string | MCP server url (streamable http servers only). |
-| `mcp_servers..bearer_token_env_var` | string | environment variable containing a bearer token to use for auth (streamable http servers only). |
-| `mcp_servers..enabled` | boolean | When false, Codex skips starting the server (default: true). |
-| `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. |
-| `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. |
-| `mcp_servers..enabled_tools` | array | Restrict the server to the listed tool names. |
-| `mcp_servers..disabled_tools` | array | Remove the listed tool names after applying `enabled_tools`, if any. |
-| `model_providers..name` | string | Display name. |
-| `model_providers..base_url` | string | API base URL. |
-| `model_providers..env_key` | string | Env var for API key. |
-| `model_providers..wire_api` | `chat` \| `responses` | Protocol used (default: `chat`). |
-| `model_providers..query_params` | map | Extra query params (e.g., Azure `api-version`). |
-| `model_providers..http_headers` | map | Additional static headers. |
-| `model_providers..env_http_headers` | map | Headers sourced from env vars. |
-| `model_providers..request_max_retries` | number | Per‑provider HTTP retry count (default: 4). |
-| `model_providers..stream_max_retries` | number | SSE stream retry count (default: 5). |
-| `model_providers..stream_idle_timeout_ms` | number | SSE idle timeout (ms) (default: 300000). |
-| `project_doc_max_bytes` | number | Max bytes to read from `AGENTS.md`. |
-| `profile` | string | Active profile name. |
-| `profiles..*` | various | Profile‑scoped overrides of the same keys. |
-| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). |
-| `history.max_bytes` | number | Maximum size of `history.jsonl` in bytes; when exceeded, history is compacted to ~80% of this limit by dropping oldest entries. |
-| `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). |
-| `tui` | table | TUI‑specific options. |
-| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: true). |
-| `tui.scroll_events_per_tick` | number | Raw events per wheel notch (normalization input; default: terminal-specific; fallback: 3). |
-| `tui.scroll_wheel_lines` | number | Lines per physical wheel notch in wheel-like mode (default: 3). |
-| `tui.scroll_trackpad_lines` | number | Baseline trackpad sensitivity in trackpad-like mode (default: 1). |
-| `tui.scroll_trackpad_accel_events` | number | Trackpad acceleration: events per +1x speed in TUI2 (default: 30). |
-| `tui.scroll_trackpad_accel_max` | number | Trackpad acceleration: max multiplier in TUI2 (default: 3). |
-| `tui.scroll_mode` | `auto` \| `wheel` \| `trackpad` | How to interpret scroll input in TUI2 (default: `auto`). |
-| `tui.scroll_wheel_tick_detect_max_ms` | number | Auto-mode threshold (ms) for promoting a stream to wheel-like behavior (default: 12). |
-| `tui.scroll_wheel_like_max_duration_ms` | number | Auto-mode fallback duration (ms) used for 1-event-per-tick terminals (default: 200). |
-| `tui.scroll_invert` | boolean | Invert mouse scroll direction in TUI2 (default: false). |
-| `hide_agent_reasoning` | boolean | Hide model reasoning events. |
-| `check_for_update_on_startup` | boolean | Check for Codex updates on startup (default: true). Set to `false` only if updates are centrally managed. |
-| `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). |
-| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high`\|`xhigh` | Responses API reasoning effort. |
-| `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. |
-| `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). |
-| `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. |
-| `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. |
-| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). |
-| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
-| `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
-| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). |
-| `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). |
-| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. |
-| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. |
-| `cli_auth_credentials_store` | `file` \| `keyring` \| `auto` | Where to store CLI login credentials (default: `file`). |
+- https://developers.openai.com/codex/config-reference
diff --git a/docs/contributing.md b/docs/contributing.md
index ec188631d1..8e73119910 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -2,9 +2,11 @@
This project is under active development and the code will likely change pretty significantly.
-**At the moment, we only plan to prioritize reviewing external contributions for bugs or security fixes.**
+**At the moment, we are generally accepting external contributions only for bugs fixes.**
-If you want to add a new feature or change the behavior of an existing one, please open an issue proposing the feature and get approval from an OpenAI team member before spending time building it.
+If you want to add a new feature or change the behavior of an existing one, please open an issue proposing the feature or upvote an existing enhancement request. We will generally prioritize new features based on community feedback. New features must compose well with existing and upcoming features and fit into our roadmap. They must also be implemented consistently across all Codex surfaces (CLI, IDE extension, web, etc.).
+
+If you want to contribute a bug fix, please open a bug report first - or verify that there is an existing bug report that discusses the issue. All bug fix PRs should include a link to a bug report.
**New contributions that don't go through this process may be closed** if they aren't aligned with our current roadmap or conflict with other priorities/upcoming features.
@@ -17,8 +19,8 @@ If you want to add a new feature or change the behavior of an existing one, plea
### Writing high-impact code changes
1. **Start with an issue.** Open a new one or comment on an existing discussion so we can agree on the solution before code is written.
-2. **Add or update tests.** Every new feature or bug-fix should come with test coverage that fails before your change and passes afterwards. 100% coverage is not required, but aim for meaningful assertions.
-3. **Document behaviour.** If your change affects user-facing behaviour, update the README, inline help (`codex --help`), or relevant example projects.
+2. **Add or update tests.** A bug fix should generally come with test coverage that fails before your change and passes afterwards. 100% coverage is not required, but aim for meaningful assertions.
+3. **Document behavior.** If your change affects user-facing behavior, update the README, inline help (`codex --help`), or relevant example projects.
4. **Keep commits atomic.** Each commit should compile and the tests should pass. This makes reviews and potential rollbacks easier.
### Opening a pull request
@@ -32,8 +34,8 @@ If you want to add a new feature or change the behavior of an existing one, plea
### Review process
1. One maintainer will be assigned as a primary reviewer.
-2. If your PR adds a new feature that was not previously discussed and approved, we may choose to close your PR (see [Contributing](#contributing)).
-3. We may ask for changes - please do not take this personally. We value the work, but we also value consistency and long-term maintainability.
+2. If your PR adds a new feature that was not previously discussed and approved, we may close your PR (see [Contributing](#contributing)).
+3. We may ask for changes. Please do not take this personally. We value the work, but we also value consistency and long-term maintainability.
4. When there is consensus that the PR meets the bar, a maintainer will squash-and-merge.
### Community values
@@ -44,7 +46,7 @@ If you want to add a new feature or change the behavior of an existing one, plea
### Getting help
-If you run into problems setting up the project, would like feedback on an idea, or just want to say _hi_ - please open a Discussion or jump into the relevant issue. We are happy to help.
+If you run into problems setting up the project, would like feedback on an idea, or just want to say _hi_ - please open a Discussion topic or jump into the relevant issue. We are happy to help.
Together we can make Codex CLI an incredible tool. **Happy hacking!** :rocket:
@@ -63,14 +65,6 @@ All contributors **must** accept the CLA. The process is lightweight:
No special Git commands, email attachments, or commit footers required.
-#### Quick fixes
-
-| Scenario | Command |
-| ----------------- | ------------------------------------------------ |
-| Amend last commit | `git commit --amend -s --no-edit && git push -f` |
-
-The **DCO check** blocks merges until every commit in the PR carries the footer (with squash this is just the one).
-
### Security & responsible AI
Have you discovered a vulnerability or have concerns about model output? Please e-mail **security@openai.com** and we will respond promptly.
diff --git a/docs/example-config.md b/docs/example-config.md
index 795b290390..84b11436c5 100644
--- a/docs/example-config.md
+++ b/docs/example-config.md
@@ -1,362 +1,3 @@
-# Example config.toml
+# Sample configuration
-Use this example configuration as a starting point. For an explanation of each field and additional context, see [Configuration](./config.md). Copy the snippet below to `~/.codex/config.toml` and adjust values as needed.
-
-```toml
-# Codex example configuration (config.toml)
-#
-# This file lists all keys Codex reads from config.toml, their default values,
-# and concise explanations. Values here mirror the effective defaults compiled
-# into the CLI. Adjust as needed.
-#
-# Notes
-# - Root keys must appear before tables in TOML.
-# - Optional keys that default to "unset" are shown commented out with notes.
-# - MCP servers, profiles, and model providers are examples; remove or edit.
-
-################################################################################
-# Core Model Selection
-################################################################################
-
-# Primary model used by Codex. Default: "gpt-5.1-codex-max" on all platforms.
-model = "gpt-5.1-codex-max"
-
-# Model used by the /review feature (code reviews). Default: "gpt-5.1-codex-max".
-review_model = "gpt-5.1-codex-max"
-
-# Provider id selected from [model_providers]. Default: "openai".
-model_provider = "openai"
-
-# Optional manual model metadata. When unset, Codex auto-detects from model.
-# Uncomment to force values.
-# model_context_window = 128000 # tokens; default: auto for model
-# model_auto_compact_token_limit = 0 # disable/override auto; default: model family specific
-# tool_output_token_limit = 10000 # tokens stored per tool output; default: 10000 for gpt-5.1-codex-max
-
-################################################################################
-# Reasoning & Verbosity (Responses API capable models)
-################################################################################
-
-# Reasoning effort: minimal | low | medium | high | xhigh (default: medium; xhigh on gpt-5.1-codex-max and gpt-5.2)
-model_reasoning_effort = "medium"
-
-# Reasoning summary: auto | concise | detailed | none (default: auto)
-model_reasoning_summary = "auto"
-
-# Text verbosity for GPT-5 family (Responses API): low | medium | high (default: medium)
-model_verbosity = "medium"
-
-# Force-enable reasoning summaries for current model (default: false)
-model_supports_reasoning_summaries = false
-
-################################################################################
-# Instruction Overrides
-################################################################################
-
-# Additional user instructions inject before AGENTS.md. Default: unset.
-# developer_instructions = ""
-
-# (Ignored) Optional legacy base instructions override (prefer AGENTS.md). Default: unset.
-# instructions = ""
-
-# Inline override for the history compaction prompt. Default: unset.
-# compact_prompt = ""
-
-# Override built-in base instructions with a file path. Default: unset.
-# experimental_instructions_file = "/absolute/or/relative/path/to/instructions.txt"
-
-# Load the compact prompt override from a file. Default: unset.
-# experimental_compact_prompt_file = "/absolute/or/relative/path/to/compact_prompt.txt"
-
-################################################################################
-# Approval & Sandbox
-################################################################################
-
-# When to ask for command approval:
-# - untrusted: only known-safe read-only commands auto-run; others prompt
-# - on-failure: auto-run in sandbox; prompt only on failure for escalation
-# - on-request: model decides when to ask (default)
-# - never: never prompt (risky)
-approval_policy = "on-request"
-
-# Filesystem/network sandbox policy for tool calls:
-# - read-only (default)
-# - workspace-write
-# - danger-full-access (no sandbox; extremely risky)
-sandbox_mode = "read-only"
-
-# Extra settings used only when sandbox_mode = "workspace-write".
-[sandbox_workspace_write]
-# Additional writable roots beyond the workspace (cwd). Default: []
-writable_roots = []
-# Allow outbound network access inside the sandbox. Default: false
-network_access = false
-# Exclude $TMPDIR from writable roots. Default: false
-exclude_tmpdir_env_var = false
-# Exclude /tmp from writable roots. Default: false
-exclude_slash_tmp = false
-
-################################################################################
-# Shell Environment Policy for spawned processes
-################################################################################
-
-[shell_environment_policy]
-# inherit: all (default) | core | none
-inherit = "all"
-# Skip default excludes for names containing KEY/SECRET/TOKEN (case-insensitive). Default: true
-ignore_default_excludes = true
-# Case-insensitive glob patterns to remove (e.g., "AWS_*", "AZURE_*"). Default: []
-exclude = []
-# Explicit key/value overrides (always win). Default: {}
-set = {}
-# Whitelist; if non-empty, keep only matching vars. Default: []
-include_only = []
-# Experimental: run via user shell profile. Default: false
-experimental_use_profile = false
-
-################################################################################
-# History & File Opener
-################################################################################
-
-[history]
-# save-all (default) | none
-persistence = "save-all"
-# Maximum bytes for history file; oldest entries are trimmed when exceeded. Example: 5242880
-# max_bytes = 0
-
-# URI scheme for clickable citations: vscode (default) | vscode-insiders | windsurf | cursor | none
-file_opener = "vscode"
-
-################################################################################
-# UI, Notifications, and Misc
-################################################################################
-
-[tui]
-# Desktop notifications from the TUI: boolean or filtered list. Default: true
-# Examples: false | ["agent-turn-complete", "approval-requested"]
-notifications = false
-
-# Enables welcome/status/spinner animations. Default: true
-animations = true
-
-# Suppress internal reasoning events from output. Default: false
-hide_agent_reasoning = false
-
-# Show raw reasoning content when available. Default: false
-show_raw_agent_reasoning = false
-
-# Disable burst-paste detection in the TUI. Default: false
-disable_paste_burst = false
-
-# Track Windows onboarding acknowledgement (Windows only). Default: false
-windows_wsl_setup_acknowledged = false
-
-# External notifier program (argv array). When unset: disabled.
-# Example: notify = ["notify-send", "Codex"]
-# notify = [ ]
-
-# In-product notices (mostly set automatically by Codex).
-[notice]
-# hide_full_access_warning = true
-# hide_rate_limit_model_nudge = true
-
-################################################################################
-# Authentication & Login
-################################################################################
-
-# Where to persist CLI login credentials: file (default) | keyring | auto
-cli_auth_credentials_store = "file"
-
-# Base URL for ChatGPT auth flow (not OpenAI API). Default:
-chatgpt_base_url = "https://chatgpt.com/backend-api/"
-
-# Restrict ChatGPT login to a specific workspace id. Default: unset.
-# forced_chatgpt_workspace_id = ""
-
-# Force login mechanism when Codex would normally auto-select. Default: unset.
-# Allowed values: chatgpt | api
-# forced_login_method = "chatgpt"
-
-# Preferred store for MCP OAuth credentials: auto (default) | file | keyring
-mcp_oauth_credentials_store = "auto"
-
-################################################################################
-# Project Documentation Controls
-################################################################################
-
-# Max bytes from AGENTS.md to embed into first-turn instructions. Default: 32768
-project_doc_max_bytes = 32768
-
-# Ordered fallbacks when AGENTS.md is missing at a directory level. Default: []
-project_doc_fallback_filenames = []
-
-################################################################################
-# Tools (legacy toggles kept for compatibility)
-################################################################################
-
-[tools]
-# Enable web search tool (alias: web_search_request). Default: false
-web_search = false
-
-# Enable the view_image tool so the agent can attach local images. Default: true
-view_image = true
-
-# (Alias accepted) You can also write:
-# web_search_request = false
-
-################################################################################
-# Centralized Feature Flags (preferred)
-################################################################################
-
-[features]
-# Leave this table empty to accept defaults. Set explicit booleans to opt in/out.
-unified_exec = false
-apply_patch_freeform = false
-view_image_tool = true
-web_search_request = false
-enable_experimental_windows_sandbox = false
-skills = false
-
-################################################################################
-# Experimental toggles (legacy; prefer [features])
-################################################################################
-
-# Include apply_patch via freeform editing path (affects default tool set). Default: false
-experimental_use_freeform_apply_patch = false
-
-# Define MCP servers under this table. Leave empty to disable.
-[mcp_servers]
-
-# --- Example: STDIO transport ---
-# [mcp_servers.docs]
-# command = "docs-server" # required
-# args = ["--port", "4000"] # optional
-# env = { "API_KEY" = "value" } # optional key/value pairs copied as-is
-# env_vars = ["ANOTHER_SECRET"] # optional: forward these from the parent env
-# cwd = "/path/to/server" # optional working directory override
-# startup_timeout_sec = 10.0 # optional; default 10.0 seconds
-# # startup_timeout_ms = 10000 # optional alias for startup timeout (milliseconds)
-# tool_timeout_sec = 60.0 # optional; default 60.0 seconds
-# enabled_tools = ["search", "summarize"] # optional allow-list
-# disabled_tools = ["slow-tool"] # optional deny-list (applied after allow-list)
-
-# --- Example: Streamable HTTP transport ---
-# [mcp_servers.github]
-# url = "https://github-mcp.example.com/mcp" # required
-# bearer_token_env_var = "GITHUB_TOKEN" # optional; Authorization: Bearer
-# http_headers = { "X-Example" = "value" } # optional static headers
-# env_http_headers = { "X-Auth" = "AUTH_ENV" } # optional headers populated from env vars
-# startup_timeout_sec = 10.0 # optional
-# tool_timeout_sec = 60.0 # optional
-# enabled_tools = ["list_issues"] # optional allow-list
-
-################################################################################
-# Model Providers (extend/override built-ins)
-################################################################################
-
-# Built-ins include:
-# - openai (Responses API; requires login or OPENAI_API_KEY via auth flow)
-# - oss (Chat Completions API; defaults to http://localhost:11434/v1)
-
-[model_providers]
-
-# --- Example: override OpenAI with explicit base URL or headers ---
-# [model_providers.openai]
-# name = "OpenAI"
-# base_url = "https://api.openai.com/v1" # default if unset
-# wire_api = "responses" # "responses" | "chat" (default varies)
-# # requires_openai_auth = true # built-in OpenAI defaults to true
-# # request_max_retries = 4 # default 4; max 100
-# # stream_max_retries = 5 # default 5; max 100
-# # stream_idle_timeout_ms = 300000 # default 300_000 (5m)
-# # experimental_bearer_token = "sk-example" # optional dev-only direct bearer token
-# # http_headers = { "X-Example" = "value" }
-# # env_http_headers = { "OpenAI-Organization" = "OPENAI_ORGANIZATION", "OpenAI-Project" = "OPENAI_PROJECT" }
-
-# --- Example: Azure (Chat/Responses depending on endpoint) ---
-# [model_providers.azure]
-# name = "Azure"
-# base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
-# wire_api = "responses" # or "chat" per endpoint
-# query_params = { api-version = "2025-04-01-preview" }
-# env_key = "AZURE_OPENAI_API_KEY"
-# # env_key_instructions = "Set AZURE_OPENAI_API_KEY in your environment"
-
-# --- Example: Local OSS (e.g., Ollama-compatible) ---
-# [model_providers.ollama]
-# name = "Ollama"
-# base_url = "http://localhost:11434/v1"
-# wire_api = "chat"
-
-################################################################################
-# Profiles (named presets)
-################################################################################
-
-# Active profile name. When unset, no profile is applied.
-# profile = "default"
-
-[profiles]
-
-# [profiles.default]
-# model = "gpt-5.1-codex-max"
-# model_provider = "openai"
-# approval_policy = "on-request"
-# sandbox_mode = "read-only"
-# model_reasoning_effort = "medium"
-# model_reasoning_summary = "auto"
-# model_verbosity = "medium"
-# chatgpt_base_url = "https://chatgpt.com/backend-api/"
-# experimental_compact_prompt_file = "./compact_prompt.txt"
-# include_apply_patch_tool = false
-# experimental_use_freeform_apply_patch = false
-# tools_web_search = false
-# tools_view_image = true
-# features = { unified_exec = false }
-
-################################################################################
-# Projects (trust levels)
-################################################################################
-
-# Mark specific worktrees as trusted. Only "trusted" is recognized.
-[projects]
-# [projects."/absolute/path/to/project"]
-# trust_level = "trusted"
-
-################################################################################
-# OpenTelemetry (OTEL) – disabled by default
-################################################################################
-
-[otel]
-# Include user prompt text in logs. Default: false
-log_user_prompt = false
-# Environment label applied to telemetry. Default: "dev"
-environment = "dev"
-# Exporter: none (default) | otlp-http | otlp-grpc
-exporter = "none"
-
-# Example OTLP/HTTP exporter configuration
-# [otel.exporter."otlp-http"]
-# endpoint = "https://otel.example.com/v1/logs"
-# protocol = "binary" # "binary" | "json"
-
-# [otel.exporter."otlp-http".headers]
-# "x-otlp-api-key" = "${OTLP_TOKEN}"
-
-# Example OTLP/gRPC exporter configuration
-# [otel.exporter."otlp-grpc"]
-# endpoint = "https://otel.example.com:4317",
-# headers = { "x-otlp-meta" = "abc123" }
-
-# Example OTLP exporter with mutual TLS
-# [otel.exporter."otlp-http"]
-# endpoint = "https://otel.example.com/v1/logs"
-# protocol = "binary"
-
-# [otel.exporter."otlp-http".headers]
-# "x-otlp-api-key" = "${OTLP_TOKEN}"
-
-# [otel.exporter."otlp-http".tls]
-# ca-certificate = "certs/otel-ca.pem"
-# client-certificate = "/etc/codex/certs/client.pem"
-# client-private-key = "/etc/codex/certs/client-key.pem"
-```
+For a sample configuration file, see [this documentation](https://developers.openai.com/codex/config-sample).
diff --git a/docs/exec.md b/docs/exec.md
index 5a17155a82..57e432305b 100644
--- a/docs/exec.md
+++ b/docs/exec.md
@@ -1,114 +1,3 @@
-## Non-interactive mode
+# Non-interactive mode
-Use Codex in non-interactive mode to automate common workflows.
-
-```shell
-codex exec "count the total number of lines of code in this project"
-```
-
-In non-interactive mode, Codex does not ask for command or edit approvals. By default it runs in `read-only` mode, so it cannot edit files or run commands that require network access.
-
-Use `codex exec --full-auto` to allow file edits. Use `codex exec --sandbox danger-full-access` to allow edits and networked commands.
-
-### Default output mode
-
-By default, Codex streams its activity to stderr and only writes the final message from the agent to stdout. This makes it easier to pipe `codex exec` into another tool without extra filtering.
-
-To write the output of `codex exec` to a file, in addition to using a shell redirect like `>`, there is also a dedicated flag to specify an output file: `-o`/`--output-last-message`.
-
-### JSON output mode
-
-`codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs.
-
-Supported event types:
-
-- `thread.started` - when a thread is started or resumed.
-- `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response.
-- `turn.completed` - when a turn completes; includes token usage.
-- `turn.failed` - when a turn fails; includes error details.
-- `item.started`/`item.updated`/`item.completed` - when a thread item is added/updated/completed.
-- `error` - when the stream reports an unrecoverable error; includes the error message.
-
-Supported item types:
-
-- `agent_message` - assistant message.
-- `reasoning` - a summary of the assistant's thinking.
-- `command_execution` - assistant executing a command.
-- `file_change` - assistant making file changes.
-- `mcp_tool_call` - assistant calling an MCP tool.
-- `web_search` - assistant performing a web search.
-- `todo_list` - the agent's running plan when the plan tool is active, updating as steps change.
-
-Typically, an `agent_message` is added at the end of the turn.
-
-Sample output:
-
-```jsonl
-{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
-{"type":"turn.started"}
-{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}}
-{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}}
-{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}}
-{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}}
-{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Yep — there’s a `README.md` in the repository root."}}
-{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}
-```
-
-### Structured output
-
-By default, the agent responds with natural language. Use `--output-schema` to provide a JSON Schema that defines the expected JSON output.
-
-The JSON Schema must follow the [strict schema rules](https://platform.openai.com/docs/guides/structured-outputs).
-
-Sample schema:
-
-```json
-{
- "type": "object",
- "properties": {
- "project_name": { "type": "string" },
- "programming_languages": { "type": "array", "items": { "type": "string" } }
- },
- "required": ["project_name", "programming_languages"],
- "additionalProperties": false
-}
-```
-
-```shell
-codex exec "Extract details of the project" --output-schema ~/schema.json
-...
-
-{"project_name":"Codex CLI","programming_languages":["Rust","TypeScript","Shell"]}
-```
-
-Combine `--output-schema` with `-o` to only print the final JSON output. You can also pass a file path to `-o` to save the JSON output to a file.
-
-### Git repository requirement
-
-Codex requires a Git repository to avoid destructive changes. To disable this check, use `codex exec --skip-git-repo-check`.
-
-### Resuming non-interactive sessions
-
-Resume a previous non-interactive session with `codex exec resume ` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent.
-
-```shell
-codex exec "Review the change, look for use-after-free issues"
-codex exec resume --last "Fix use-after-free issues"
-```
-
-Only the conversation context is preserved; you must still provide flags to customize Codex behavior.
-
-```shell
-codex exec --model gpt-5.1-codex-max --json "Review the change, look for use-after-free issues"
-codex exec --model gpt-5.1 --json resume --last "Fix use-after-free issues"
-```
-
-## Authentication
-
-By default, `codex exec` will use the same authentication method as Codex CLI and VSCode extension. You can override the api key by setting the `CODEX_API_KEY` environment variable.
-
-```shell
-CODEX_API_KEY=your-api-key-here codex exec "Fix merge conflict"
-```
-
-NOTE: `CODEX_API_KEY` is only supported in `codex exec`.
+For information about non-interactive mode, see [this documentation](https://developers.openai.com/codex/noninteractive).
diff --git a/docs/execpolicy.md b/docs/execpolicy.md
index ecc79f33d2..cafebb32ee 100644
--- a/docs/execpolicy.md
+++ b/docs/execpolicy.md
@@ -1,74 +1,3 @@
-# Execpolicy quickstart
+# Execution policy
-Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in `.rules` files under `~/.codex/rules`.
-
-## How to create and edit rules
-
-### TUI interactions
-
-Codex CLI will present the option to whitelist commands when a command causes a prompt.
-
-
-
-Whitelisted commands will no longer require your permission to run in current and subsequent sessions.
-
-Under the hood, when you approve and whitelist a command, codex will edit `~/.codex/rules/default.rules`.
-
-### Editing `.rules` files
-
-1. Create a policy directory: `mkdir -p ~/.codex/rules`.
-2. Add one or more `.rules` files in that folder. Codex automatically loads every `.rules` file in there on startup.
-3. Write `prefix_rule` entries to describe the commands you want to allow, prompt, or block:
-
-```starlark
-prefix_rule(
- pattern = ["git", ["push", "fetch"]],
- decision = "prompt", # allow | prompt | forbidden
- match = [["git", "push", "origin", "main"]], # examples that must match
- not_match = [["git", "status"]], # examples that must not match
-)
-```
-
-- `pattern` is a list of shell tokens, evaluated from left to right; wrap tokens in a nested list to express alternatives (for example, match both `push` and `fetch`).
-- `decision` sets the severity; Codex picks the strictest decision when multiple rules match (forbidden > prompt > allow).
-- `match` and `not_match` act as optional unit tests. Codex validates them when it loads your policy, so you get feedback if an example has unexpected behavior.
-
-In this example rule, if Codex wants to run commands with the prefix `git push` or `git fetch`, it will first ask for user approval.
-
-## Preview decisions
-
-Use the `codex execpolicy check` subcommand to preview decisions before you save a rule (see the [`codex-execpolicy` README](../codex-rs/execpolicy/README.md) for syntax details):
-
-```shell
-codex execpolicy check --rules ~/.codex/rules/default.rules git push origin main
-```
-
-Pass multiple `--rules` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax.
-
-Example output when a rule matches:
-
-```json
-{
- "matchedRules": [
- {
- "prefixRuleMatch": {
- "matchedPrefix": ["git", "push"],
- "decision": "prompt"
- }
- }
- ],
- "decision": "prompt"
-}
-```
-
-When no rules match, `matchedRules` is an empty array and `decision` is omitted.
-
-```json
-{
- "matchedRules": []
-}
-```
-
-## Status
-
-`execpolicy` commands are still in preview. The API may have breaking changes in the future.
+For an overview of execution policy rules, see [this documentation](https://developers.openai.com/codex/exec-policy).
diff --git a/docs/experimental.md b/docs/experimental.md
deleted file mode 100644
index 358a23409d..0000000000
--- a/docs/experimental.md
+++ /dev/null
@@ -1,10 +0,0 @@
-## Experimental technology disclaimer
-
-Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
-
-- Bug reports
-- Feature requests
-- Pull requests
-- Good vibes
-
-Help us improve by filing issues or submitting PRs (see [contributing.md](./contributing.md) for guidance)!
diff --git a/docs/faq.md b/docs/faq.md
deleted file mode 100644
index 93776b957a..0000000000
--- a/docs/faq.md
+++ /dev/null
@@ -1,55 +0,0 @@
-## FAQ
-
-This FAQ highlights the most common questions and points you to the right deep-dive guides in `docs/`.
-
-### OpenAI released a model called Codex in 2021 - is this related?
-
-In 2021, OpenAI released Codex, an AI system designed to generate code from natural language prompts. That original Codex model was deprecated as of March 2023 and is separate from the CLI tool.
-
-### Which models are supported?
-
-We recommend using Codex with GPT-5.1 Codex Max, our best coding model. The default reasoning level is medium, and you can upgrade to high or xhigh (where supported, e.g. `gpt-5.1-codex-max` and `gpt-5.2`) for complex tasks with the `/model` command.
-
-You can also use older models by using API-based auth and launching codex with the `--model` flag.
-
-### How do approvals and sandbox modes work together?
-
-Approvals are the mechanism Codex uses to ask before running a tool call with elevated permissions - typically to leave the sandbox or re-run a failed command without isolation. Sandbox mode provides the baseline isolation (`Read Only`, `Workspace Write`, or `Danger Full Access`; see [Sandbox & approvals](./sandbox.md)).
-
-### Can I automate tasks without the TUI?
-
-Yes. [`codex exec`](./exec.md) runs Codex in non-interactive mode with streaming logs, JSONL output, and structured schema support. The command respects the same sandbox and approval settings you configure in the [Config guide](./config.md).
-
-### How do I stop Codex from editing my files?
-
-By default, Codex can modify files in your current working directory (Auto mode). To prevent edits, run `codex` in read-only mode with the CLI flag `--sandbox read-only`. Alternatively, you can change the approval level mid-conversation with `/approvals`.
-
-### How do I connect Codex to MCP servers?
-
-Configure MCP servers through your `config.toml` using the examples in [Config -> Connecting to MCP servers](./config.md#connecting-to-mcp-servers).
-
-### I'm having trouble logging in. What should I check?
-
-Confirm your setup in three steps:
-
-1. Walk through the auth flows in [Authentication](./authentication.md) to ensure the correct credentials are present in `~/.codex/auth.json`.
-2. If you're on a headless or remote machine, make sure port-forwarding is configured as described in [Authentication -> Connecting on a "Headless" Machine](./authentication.md#connecting-on-a-headless-machine).
-
-### Does it work on Windows?
-
-Running Codex directly on Windows may work, but is not officially supported. We recommend using [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install).
-
-### Where should I start after installation?
-
-Follow the quick setup in [Install & build](./install.md) and then jump into [Getting started](./getting-started.md) for interactive usage tips, prompt examples, and AGENTS.md guidance.
-
-### `brew upgrade codex` isn't upgrading me
-
-If you're running Codex v0.46.0 or older, `brew upgrade codex` will not move you to the latest version because we migrated from a Homebrew formula to a cask. To upgrade, uninstall the existing oudated formula and then install the new cask:
-
-```bash
-brew uninstall --formula codex
-brew install --cask codex
-```
-
-After reinstalling, `brew upgrade --cask codex` will keep future releases up to date.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 923eb09568..b2ea5f2642 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,120 +1,3 @@
-## Getting started
+# Getting started with Codex CLI
-Looking for something specific? Jump ahead:
-
-- [Tips & shortcuts](#tips--shortcuts) – hotkeys, resume flow, prompts
-- [Non-interactive runs](./exec.md) – automate with `codex exec`
-- Ready for deeper customization? Head to [`advanced.md`](./advanced.md)
-
-### CLI usage
-
-| Command | Purpose | Example |
-| ------------------ | ---------------------------------- | ------------------------------- |
-| `codex` | Interactive TUI | `codex` |
-| `codex "..."` | Initial prompt for interactive TUI | `codex "fix lint errors"` |
-| `codex exec "..."` | Non-interactive "automation mode" | `codex exec "explain utils.ts"` |
-
-Key flags: `--model/-m`, `--ask-for-approval/-a`.
-
-### Resuming interactive sessions
-
-- Run `codex resume` to display the session picker UI
-- Resume most recent: `codex resume --last`
-- Resume by id: `codex resume ` (You can get session ids from /status or `~/.codex/sessions/`)
-- The picker shows the session's recorded Git branch when available.
-- To show the session's original working directory (CWD), run `codex resume --all` (this also disables cwd filtering and adds a `CWD` column).
-
-Examples:
-
-```shell
-# Open a picker of recent sessions
-codex resume
-
-# Resume the most recent session
-codex resume --last
-
-# Resume a specific session by id
-codex resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc
-```
-
-### Running with a prompt as input
-
-You can also run Codex CLI with a prompt as input:
-
-```shell
-codex "explain this codebase to me"
-```
-
-### Example prompts
-
-Below are a few bite-size examples you can copy-paste. Replace the text in quotes with your own task.
-
-| ✨ | What you type | What happens |
-| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
-| 1 | `codex "Refactor the Dashboard component to React Hooks"` | Codex rewrites the class component, runs `npm test`, and shows the diff. |
-| 2 | `codex "Generate SQL migrations for adding a users table"` | Infers your ORM, creates migration files, and runs them in a sandboxed DB. |
-| 3 | `codex "Write unit tests for utils/date.ts"` | Generates tests, executes them, and iterates until they pass. |
-| 4 | `codex "Bulk-rename *.jpeg -> *.jpg with git mv"` | Safely renames files and updates imports/usages. |
-| 5 | `codex "Explain what this regex does: ^(?=.*[A-Z]).{8,}$"` | Outputs a step-by-step human explanation. |
-| 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. |
-| 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. |
-
-Looking to reuse your own instructions? Create slash commands with [custom prompts](./prompts.md).
-
-### Memory with AGENTS.md
-
-You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for them in the following places, and merges them top-down:
-
-1. `~/.codex/AGENTS.md` - personal global guidance
-2. Every directory from the repository root down to your current working directory (inclusive). In each directory, Codex first looks for `AGENTS.override.md` and uses it if present; otherwise it falls back to `AGENTS.md`. Use the override form when you want to replace inherited instructions for that directory.
-
-For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](https://agents.md/).
-
-### Tips & shortcuts
-
-#### Use `@` for file search
-
-Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search.
-
-#### Esc–Esc to edit a previous message
-
-When the chat composer is empty, press Esc to prime “backtrack” mode. Press Esc again to open a transcript preview highlighting the last user message; press Esc repeatedly to step to older user messages. Press Enter to confirm and Codex will fork the conversation from that point, trim the visible transcript accordingly, and pre‑fill the composer with the selected user message so you can edit and resubmit it.
-
-In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active.
-
-#### `--cd`/`-C` flag
-
-Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session.
-
-#### `--add-dir` flag
-
-Need to work across multiple projects in one run? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example:
-
-```shell
-codex --cd apps/frontend --add-dir ../backend --add-dir ../shared
-```
-
-Codex can then inspect and edit files in each listed directory without leaving the primary workspace.
-
-#### Shell completions
-
-Generate shell completion scripts via:
-
-```shell
-codex completion bash
-codex completion zsh
-codex completion fish
-```
-
-#### Image input
-
-Paste images directly into the composer (Ctrl+V / Cmd+V) to attach them to your prompt. You can also attach files via the CLI using `-i/--image` (comma‑separated):
-
-```bash
-codex -i screenshot.png "Explain this error"
-codex --image img1.png,img2.jpg "Summarize these diagrams"
-```
-
-#### Environment variables and executables
-
-Make sure your environment is already set up before launching Codex so it does not spend tokens probing what to activate. For example, source your Python virtualenv (or other language runtimes), start any required daemons, and export the env vars you expect to use ahead of time.
+For an overview of Codex CLI features, see [this documentation](https://developers.openai.com/codex/cli/features#running-in-interactive-mode).
diff --git a/docs/install.md b/docs/install.md
index b54b74f16c..20d8b54a54 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -1,4 +1,4 @@
-## Install & build
+## Installing & building
### System requirements
@@ -26,7 +26,7 @@ rustup component add rustfmt
rustup component add clippy
# Install helper tools used by the workspace justfile:
cargo install just
-# Optional: install nextest for the `just test` helper (or use `cargo test --all-features` as a fallback)
+# Optional: install nextest for the `just test` helper
cargo install cargo-nextest
# Build Codex.
@@ -41,8 +41,22 @@ just fix -p
# Run the relevant tests (project-specific is fastest), for example:
cargo test -p codex-tui
-# If you have cargo-nextest installed, `just test` runs the full suite:
+# If you have cargo-nextest installed, `just test` runs the test suite via nextest:
just test
-# Otherwise, fall back to:
+# If you specifically want the full `--all-features` matrix, use:
cargo test --all-features
```
+
+## Tracing / verbose logging
+
+Codex is written in Rust, so it honors the `RUST_LOG` environment variable to configure its logging behavior.
+
+The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written:
+
+```bash
+tail -F ~/.codex/log/codex-tui.log
+```
+
+By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file.
+
+See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options.
diff --git a/docs/platform-sandboxing.md b/docs/platform-sandboxing.md
deleted file mode 100644
index e635520401..0000000000
--- a/docs/platform-sandboxing.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## Platform sandboxing
-
-This content now lives alongside the rest of the sandbox guidance. See [Sandbox mechanics by platform](./sandbox.md#sandbox-mechanics-by-platform) for up-to-date details.
diff --git a/docs/prompts.md b/docs/prompts.md
index c995cb912f..fa3da5b3bd 100644
--- a/docs/prompts.md
+++ b/docs/prompts.md
@@ -1,96 +1,3 @@
-## Custom Prompts
+# Custom prompts
-Custom prompts turn your repeatable instructions into reusable slash commands, so you can trigger them without retyping or copy/pasting. Each prompt is a Markdown file that Codex expands into the conversation the moment you run it.
-
-### Where prompts live
-
-- Location: store prompts in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). Set `CODEX_HOME` if you want to use a different folder.
-- File type: Codex only loads `.md` files. Non-Markdown files are ignored. Both regular files and symlinks to Markdown files are supported.
-- Naming: The filename (without `.md`) becomes the prompt name. A file called `review.md` registers the prompt `review`.
-- Refresh: Prompts are loaded when a session starts. Restart Codex (or start a new session) after adding or editing files.
-- Conflicts: Files whose names collide with built-in commands (like `init`) stay hidden in the slash popup, but you can still invoke them with `/prompts: |