mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge f0ccf9fcb4 into sapling-pr-archive-bolinfest
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 408 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 818 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 MiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
|
||||
|
||||
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
|
||||
</br>
|
||||
</br>If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE</a>
|
||||
</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, go to <a href="https://chatgpt.com/codex">chatgpt.com/codex</a></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
|
||||
</p>
|
||||
</p>
|
||||
</br>
|
||||
If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE.</a>
|
||||
</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, go to <a href="https://chatgpt.com/codex">chatgpt.com/codex</a>.</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
<details>
|
||||
<summary>You can also go to the <a href="https://github.com/openai/codex/releases/latest">latest GitHub Release</a> and download the appropriate binary for your platform.</summary>
|
||||
@@ -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
|
||||
|
||||
<p align="center">
|
||||
<img src="./.github/codex-cli-login.png" alt="Codex CLI login" width="80%" />
|
||||
</p>
|
||||
|
||||
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).
|
||||
|
||||
Generated
+222
-85
@@ -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"
|
||||
|
||||
+13
-12
@@ -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"
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -1274,6 +1274,8 @@ pub struct Turn {
|
||||
pub struct TurnError {
|
||||
pub message: String,
|
||||
pub codex_error_info: Option<CodexErrorInfo>,
|
||||
#[serde(default)]
|
||||
pub additional_details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -59,3 +59,61 @@ prefix_rule(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_check_includes_forbidden_reason_when_present()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub enum ResponseEvent {
|
||||
summary_index: i64,
|
||||
},
|
||||
RateLimits(RateLimitSnapshot),
|
||||
ModelsEtag(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
|
||||
&self,
|
||||
client_version: &str,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<ModelsResponse, ApiError> {
|
||||
) -> Result<(Vec<ModelInfo>, Option<String>), 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<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToString::to_string);
|
||||
|
||||
let ModelsResponse { models, etag } = serde_json::from_slice::<ModelsResponse>(&resp.body)
|
||||
let ModelsResponse { models } = serde_json::from_slice::<ModelsResponse>(&resp.body)
|
||||
.map_err(|e| {
|
||||
ApiError::Stream(format!(
|
||||
"failed to decode models response: {e}; body: {}",
|
||||
@@ -74,9 +75,7 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
|
||||
))
|
||||
})?;
|
||||
|
||||
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<Mutex<Option<Request>>>,
|
||||
body: Arc<ModelsResponse>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value>, 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<S>(
|
||||
stream: S,
|
||||
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
@@ -57,6 +72,31 @@ pub async fn process_chat_sse<S>(
|
||||
let mut reasoning_item: Option<ResponseItem> = None;
|
||||
let mut completed_sent = false;
|
||||
|
||||
async fn flush_and_complete(
|
||||
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
reasoning_item: &mut Option<ResponseItem>,
|
||||
assistant_item: &mut Option<ResponseItem>,
|
||||
) {
|
||||
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<S>(
|
||||
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<S>(
|
||||
|
||||
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<ResponseEvent> {
|
||||
let reader = ReaderStream::new(std::io::Cursor::new(body.to_string()))
|
||||
.map_err(|err| codex_client::TransportError::Network(err.to_string()));
|
||||
|
||||
@@ -51,11 +51,19 @@ pub fn spawn_response_stream(
|
||||
telemetry: Option<Arc<dyn SseTelemetry>>,
|
||||
) -> 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::<Result<ResponseEvent, ApiError>>(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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -69,6 +69,15 @@ impl ReqwestTransport {
|
||||
#[async_trait]
|
||||
impl HttpTransport for ReqwestTransport {
|
||||
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
|
||||
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();
|
||||
|
||||
+119
-4
@@ -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<TokenUsageInfo> {
|
||||
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<String>,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -184,6 +184,10 @@ async fn forward_events(
|
||||
id: _,
|
||||
msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::TokenCount(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Option<TomlValue>> {
|
||||
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<Option<ConfigRequirementsToml>> {
|
||||
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<Option<String>> {
|
||||
#[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<TomlValue> {
|
||||
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::<TomlValue>(&decoded_str) {
|
||||
fn parse_managed_config_base64(encoded: &str) -> io::Result<TomlValue> {
|
||||
match toml::from_str::<TomlValue>(&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<ConfigRequirementsToml> {
|
||||
toml::from_str::<ConfigRequirementsToml>(&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> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,8 +78,14 @@ pub async fn load_config_layers_state(
|
||||
) -> io::Result<ConfigLayerStack> {
|
||||
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::<ConfigLayerEntry>::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) {
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub managed_preferences_base64: Option<String>,
|
||||
pub macos_managed_config_requirements_base64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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<String> {
|
||||
})
|
||||
}
|
||||
|
||||
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<Path>) -> Result<Vec<PathBuf>, 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ impl Features {
|
||||
|
||||
features
|
||||
}
|
||||
|
||||
pub fn enabled_features(&self) -> Vec<Feature> {
|
||||
self.enabled.iter().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys accepted in `[features]` tables.
|
||||
|
||||
@@ -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<I>(tools: I) -> HashMap<String, ToolInfo>
|
||||
where
|
||||
I: IntoIterator<Item = ToolInfo>,
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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<ModelPreset> {
|
||||
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<String> {
|
||||
self.etag.read().await.clone()
|
||||
}
|
||||
|
||||
/// Replace the cached remote models and rebuild the derived presets list.
|
||||
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
|
||||
*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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<serde_json::Value>().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;
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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": ["<token>", "..."],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
"decision": "allow|prompt|forbidden",
|
||||
"forbiddenReason": "..."
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
prefix_rule(
|
||||
pattern = ["git", "reset", "--hard"],
|
||||
decision = "forbidden",
|
||||
forbidden_reason = "destructive operation",
|
||||
match = [
|
||||
["git", "reset", "--hard"],
|
||||
],
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -212,6 +212,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
decision: Option<&'v str>,
|
||||
r#match: Option<UnpackList<Value<'v>>>,
|
||||
not_match: Option<UnpackList<Value<'v>>>,
|
||||
forbidden_reason: Option<&'v str>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
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<Vec<String>> =
|
||||
@@ -246,6 +264,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
rest: rest.clone(),
|
||||
},
|
||||
decision,
|
||||
forbidden_reason: forbidden_reason.clone(),
|
||||
}) as RuleRef
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Policy {
|
||||
.into(),
|
||||
},
|
||||
decision,
|
||||
forbidden_reason: None,
|
||||
});
|
||||
|
||||
self.rules_by_program.insert(first_token.clone(), rule);
|
||||
|
||||
@@ -63,6 +63,11 @@ pub enum RuleMatch {
|
||||
#[serde(rename = "matchedPrefix")]
|
||||
matched_prefix: Vec<String>,
|
||||
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<String>,
|
||||
},
|
||||
HeuristicsRuleMatch {
|
||||
command: Vec<String>,
|
||||
@@ -83,6 +88,7 @@ impl RuleMatch {
|
||||
pub struct PrefixRule {
|
||||
pub pattern: PrefixPattern,
|
||||
pub decision: Decision,
|
||||
pub forbidden_reason: Option<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<PatternToken>::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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<S>(&self) -> impl Layer<S> + 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<S>(&self) -> impl Layer<S> + 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<ConversationId>) -> 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<RingBuffer>,
|
||||
tags: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
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<u8>,
|
||||
tags: BTreeMap<String, String>,
|
||||
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<FeedbackInner>,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> 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<String, String>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -187,8 +187,6 @@ pub struct ModelInfo {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
|
||||
pub struct ModelsResponse {
|
||||
pub models: Vec<ModelInfo>,
|
||||
#[serde(default)]
|
||||
pub etag: String,
|
||||
}
|
||||
|
||||
// convert ModelInfo to ModelPreset
|
||||
|
||||
@@ -844,7 +844,7 @@ pub struct TaskStartedEvent {
|
||||
pub model_context_window: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<CodexErrorInfo>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
|
||||
+7
-16
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ModelsManager>,
|
||||
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<Box<dyn HistoryCell>>,
|
||||
config: Config,
|
||||
model_family: ModelFamily,
|
||||
model: String,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
session_header: SessionHeader,
|
||||
@@ -608,12 +607,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option<i64> {
|
||||
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<i64> {
|
||||
@@ -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<String>) {
|
||||
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<ModelPreset> =
|
||||
// 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<ModelPreset>, Vec<ModelPreset>) = 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<SelectionItem> = 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<SelectionAction> = 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<String>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -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<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -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));
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+132
-109
@@ -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<Arc<dyn HistoryCell>>,
|
||||
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<Overlay>,
|
||||
@@ -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<bool> = cells
|
||||
.iter()
|
||||
.map(|c| c.as_any().is::<UserHistoryCell>())
|
||||
.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<TranscriptCopyFeedback> {
|
||||
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<bool> {
|
||||
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<Line<'static>> = 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<Line<'static>> =
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TranscriptCopyFeedback>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
}
|
||||
@@ -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<TranscriptCopyFeedback>,
|
||||
) -> 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) {
|
||||
|
||||
@@ -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<TranscriptCopyFeedback>,
|
||||
}
|
||||
|
||||
#[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<Line<'static>> {
|
||||
fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option<TranscriptCopyFeedback>) {
|
||||
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<Line<'static>> {
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,14 +388,18 @@ impl BottomPane {
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
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.
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui2/src/bottom_pane/footer.rs
|
||||
assertion_line: 473
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts · Copied "
|
||||
@@ -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<ModelsManager>,
|
||||
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<Box<dyn HistoryCell>>,
|
||||
config: Config,
|
||||
model_family: ModelFamily,
|
||||
model: String,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
session_header: SessionHeader,
|
||||
@@ -573,12 +572,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option<i64> {
|
||||
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<i64> {
|
||||
@@ -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<String>) {
|
||||
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<ModelPreset> =
|
||||
// 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<ModelPreset>, Vec<ModelPreset>) = 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<SelectionItem> = 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<SelectionAction> = 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<String>) {
|
||||
@@ -3101,12 +3096,14 @@ impl ChatWidget {
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
self.bottom_pane.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -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<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TranscriptCopyFeedbackState>,
|
||||
}
|
||||
|
||||
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<dyn HistoryCell>],
|
||||
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<TranscriptCopyFeedback> {
|
||||
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<dyn HistoryCell>],
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Span<'static>> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
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<Line<'static>> = Vec::new();
|
||||
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
|
||||
let mut joiner_before: Vec<Option<String>> = 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<dyn HistoryCell>,
|
||||
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<Arc<dyn HistoryCell>> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Rect>) {
|
||||
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<Rect>,
|
||||
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<usize> {
|
||||
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<ratatui::style::Color> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Rect>,
|
||||
pub(crate) transcript_cells: &'a [Arc<dyn HistoryCell>],
|
||||
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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Self> {
|
||||
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<usize> {
|
||||
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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u16> = 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<PathBuf>,
|
||||
write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
let mut v: Vec<u16> = 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<Vec<u8>> {
|
||||
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<u16> = 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<u8>],
|
||||
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<u8>, 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<EXPLICIT_ACCESS_W> = 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<String> = 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<PathBuf> = 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);
|
||||
|
||||
@@ -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.
|
||||
+2
-49
@@ -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).
|
||||
|
||||
+1
-66
@@ -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 <user>@<remote-host>
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
+10
-1062
File diff suppressed because it is too large
Load Diff
+9
-15
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user