Compare commits

...

56 Commits

Author SHA1 Message Date
Ahmed Ibrahim
abe596976b fix 2025-08-08 18:18:25 -07:00
Ahmed Ibrahim
6967b51065 fix 2025-08-08 18:17:21 -07:00
Ahmed Ibrahim
697f7b1300 fix 2025-08-08 18:15:33 -07:00
Ahmed Ibrahim
14a3bb51b3 refactor 2025-08-08 17:58:14 -07:00
Ahmed Ibrahim
4f590ebf44 refactor 2025-08-08 17:57:18 -07:00
aibrahim-oai
ddabd42236 feat(cli): auto-update command 2025-08-08 17:44:28 -07:00
aibrahim-oai
b3d47cfa11 feat(cli): add update command 2025-08-08 17:18:59 -07:00
pakrym-oai
307d9957fa Fix usage limit banner grammar (#2018)
## Summary
- fix typo in usage limit banner text
- update error message tests

## Testing
- `just fmt`
- `RUSTC_BOOTSTRAP=1 just fix` *(fails: `let` expressions in this
position are unstable)*
- `RUSTC_BOOTSTRAP=1 cargo test --all-features` *(fails: `let`
expressions in this position are unstable)*

------
https://chatgpt.com/codex/tasks/task_i_689610fc1fe4832081bdd1118779b60b
2025-08-08 08:50:44 -07:00
pakrym-oai
431c9299d4 Remove part of the error message (#1983) 2025-08-08 02:01:53 +00:00
easong-openai
52e12f2b6c Revert "Streaming markdown (#1920)" (#1981)
This reverts commit 2b7139859e.
2025-08-08 01:38:39 +00:00
easong-openai
2b7139859e Streaming markdown (#1920)
We wait until we have an entire newline, then format it with markdown and stream in to the UI. This reduces time to first token but is the right thing to do with our current rendering model IMO. Also lets us add word wrapping!
2025-08-07 18:26:47 -07:00
pakrym-oai
fa0051190b Adjust error messages (#1969)
<img width="1378" height="285" alt="image"
src="https://github.com/user-attachments/assets/f0283378-f839-4a1f-8331-909694a04b1f"
/>
2025-08-07 18:24:34 -07:00
Michael Bolin
cd06b28d84 fix: default to credits from ChatGPT auth, when possible (#1971)
Uses this rough strategy for authentication:

```
if auth.json
	if auth.json.API_KEY is NULL # new auth
		CHAT
	else # old auth
		if plus or pro or team
			CHAT
		else 
			API_KEY
		
else OPENAI_API_KEY
```

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1970).
* __->__ #1971
* #1970
* #1966
* #1965
* #1962
2025-08-07 18:00:31 -07:00
Michael Bolin
295abf3e51 chore: change CodexAuth::from_api_key() to take &str instead of String (#1970)
Good practice and simplifies some of the call sites.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1970).
* #1971
* __->__ #1970
* #1966
* #1965
* #1962
2025-08-07 16:55:33 -07:00
Michael Bolin
b991c04f86 chore: move top-level load_auth() to CodexAuth::from_codex_home() (#1966)
There are two valid ways to create an instance of `CodexAuth`:
`from_api_key()` and `from_codex_home()`. Now both are static methods of
`CodexAuth` and are listed first in the implementation.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1966).
* #1971
* #1970
* __->__ #1966
* #1965
* #1962
2025-08-07 16:49:37 -07:00
Michael Bolin
02c9c2ecad chore: make CodexAuth::api_key a private field (#1965)
Force callers to access this information via `get_token()` rather than
messing with it directly.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1965).
* #1971
* #1970
* #1966
* __->__ #1965
* #1962
2025-08-07 16:40:01 -07:00
Michael Bolin
db76f32888 chore: rename CodexAuth::new() to create_dummy_codex_auth_for_testing() because it is not for general consumption (#1962)
`CodexAuth::new()` was the first method listed in `CodexAuth`, but it is
only meant to be used by tests. Rename it to
`create_dummy_chatgpt_auth_for_testing()` and move it to the end of the
implementation.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1962).
* #1971
* #1970
* #1966
* #1965
* __->__ #1962
2025-08-07 16:33:29 -07:00
Dylan
548466df09 [client] Tune retries and backoff (#1956)
## Summary
10 is a bit excessive 😅 Also updates our backoff factor to space out
requests further.
2025-08-07 15:23:31 -07:00
Michael Bolin
7d67159587 fix: public load_auth() fn always called with include_env_var=true (#1961)
Apparently `include_env_var=false` was only used for testing, so clean
up the API a little to make that clear.


---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1961).
* #1962
* __->__ #1961
2025-08-07 14:19:30 -07:00
Michael Bolin
f74fe7af7b fix: fix mistaken bitwise OR in #1949 (#1957)
This is hard for me to test conclusively because I have the default of
`ctrl left/right` used to migrate between Spaces on macOS.
2025-08-07 20:11:06 +00:00
Jeremy Rose
c787603812 ctrl+arrows also move words (#1949)
this was removed at some point, but this is a common keybind for word
left/right.
2025-08-07 18:27:44 +00:00
Ed Bayes
e07776ccc9 update readme (#1948)
Co-authored-by: Alexander Embiricos <ae@openai.com>
2025-08-07 11:20:53 -07:00
pakrym-oai
f23c3066c8 Add capacity error (#1947) 2025-08-07 10:46:43 -07:00
pakrym-oai
a593b1c3ab Use different field for error type (#1945) 2025-08-07 10:20:33 -07:00
Michael Bolin
107d2ce4e7 fix: change OPENAI_DEFAULT_MODEL to "gpt-5" (#1943) 2025-08-07 10:13:13 -07:00
Ed Bayes
09adbf9132 remove composer bg (#1944)
passes local tests
2025-08-07 10:04:49 -07:00
pakrym-oai
62ed5907f9 Better usage errors (#1941)
<img width="771" height="279" alt="image"
src="https://github.com/user-attachments/assets/e56f967f-bcd7-49f7-8a94-3d88df68b65a"
/>
2025-08-07 09:46:13 -07:00
Dylan
bc28b87c7b [config] Onboarding flow with persistence (#1929)
## Summary
In collaboration with @gpeal: upgrade the onboarding flow, and persist
user settings.

---------

Co-authored-by: Gabriel Peal <gabriel@openai.com>
2025-08-07 09:27:38 -07:00
pakrym-oai
7e9ecfbc6a Rename the model (#1942) 2025-08-07 09:07:51 -07:00
pakrym-oai
c87fb83d81 Calculate remaining context based on last token usage (#1940)
We should only take last request size (in tokens) into account
2025-08-07 05:17:18 -07:00
ae
81b148bda2 feat: update system prompt (#1939) 2025-08-07 04:29:50 -07:00
ae
12d29c2779 feat: add tip to upgrade to ChatGPT plan (#1938) 2025-08-07 11:10:13 +00:00
ae
c4dc6a80bf feat: improve output of /status (#1936)
Now it looks like this:
```
/status
📂 Workspace
  • Path: ~/code/codex/codex-rs
  • Approval Mode: on-request
  • Sandbox: workspace-write

👤 Account
  • Signed in with ChatGPT
  • Login: example@example.com
  • Plan: Pro

🧠 Model
  • Name: ?!?!?!?!?!
  • Provider: OpenAI

📊 Token Usage
  • Input: 11940 (+ 7999 cached)
  • Output: 2639
  • Total: 14579
```
2025-08-07 12:02:58 +01:00
ae
7c20160676 feat: /prompts slash command (#1937)
- Shows several example prompts which include @-mentions 

------
https://chatgpt.com/codex/tasks/task_i_6894779ba8cc832ca0c871d17ee06aae
2025-08-07 11:55:59 +01:00
Ed Bayes
1e4bf81653 Update copy (#1935)
Updated copy

---------

Co-authored-by: pap-openai <pap@openai.com>
2025-08-07 03:29:33 -07:00
aibrahim-oai
5589c6089b approval ui (#1933)
Asking for approval:

<img width="269" height="41" alt="image"
src="https://github.com/user-attachments/assets/b9ced569-3297-4dae-9ce7-0b015c9e14ea"
/>

Allow:

<img width="400" height="31" alt="image"
src="https://github.com/user-attachments/assets/92056b22-efda-4d49-854d-e2943d5fcf17"
/>

Reject:

<img width="372" height="30" alt="image"
src="https://github.com/user-attachments/assets/be9530a9-7d41-4800-bb42-abb9a24fc3ea"
/>

Always Approve:

<img width="410" height="36" alt="image"
src="https://github.com/user-attachments/assets/acf871ba-4c26-4501-b303-7956d0151754"
/>
2025-08-07 02:02:56 -07:00
Michael Bolin
c2c327c723 feat: change shell_environment_policy to default to inherit="all" (#1904)
Trying to use `core` as the default has been "too clever." Users can
always take responsibility for controlling the env without this setting
at all by specifying the `env` they use when calling `codex` in the
first place.

See https://github.com/openai/codex/issues/1249.
2025-08-07 01:55:41 -07:00
Ed Bayes
20084facfe Add spinner animation to TUI status indicator (#1917)
## Summary
- add a pulsing dot loader before the shimmering `Working` label in the
status indicator widget and include a small test asserting the spinner
character is rendered
- also fix a small bug in the ran command header by adding a space
between the  and `Ran command`


https://github.com/user-attachments/assets/6768c9d2-e094-49cb-ad51-44bcac10aa6f

## Testing
- `just fmt`
- `just fix` *(failed: E0658 `let` expressions in core/src/client.rs)*
- `cargo test --all-features` *(failed: E0658 `let` expressions in
core/src/client.rs)*

------
https://chatgpt.com/codex/tasks/task_i_68941bffdb948322b0f4190bc9dbe7f6

---------

Co-authored-by: aibrahim-oai <aibrahim@openai.com>
2025-08-07 08:45:04 +00:00
Michael Bolin
13982d6b4e chore: fix outstanding review comments from the bot on #1919 (#1928)
I should have read the comments before submitting!
2025-08-07 01:30:13 -07:00
ae
0334476894 feat: parse info from auth.json and show in /status (#1923)
- `/status` renders
    ```
    signed in with chatgpt
      login: example@example.com
      plan: plus
    ```
- Setup for using this info in a few more places.

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-08-07 01:27:45 -07:00
Gabriel Peal
6d19b73edf Add logout command to CLI and TUI (#1932)
## Summary
- support `codex logout` via new subcommand and helper that removes the
stored `auth.json`
- expose a `logout` function in `codex-login` and test it
- add `/logout` slash command in the TUI; command list is filtered when
not logged in and the handler deletes `auth.json` then exits

## Testing
- `just fix` *(fails: failed to get `diffy` from crates.io)*
- `cargo test --all-features` *(fails: failed to get `diffy` from
crates.io)*

------
https://chatgpt.com/codex/tasks/task_i_68945c3facac832ca83d48499716fb51
2025-08-07 04:17:33 -04:00
ae
28395df957 [fix] fix absolute and % token counts (#1931)
- For absolute, use non-cached input + output.
- For estimating what % of the model's context window is used, we need
to account for reasoning output tokens from prior turns being dropped
from the context window. We approximate this here by subtracting
reasoning output tokens from the total. This will be off for the current
turn and pending function calls. We can improve it later.
2025-08-07 08:13:36 +00:00
Ed Bayes
eb80614a7c Tint chat composer background (#1921)
## Summary
- give the chat composer a subtle custom background and apply it across
the full area drawn

<img width="1008" height="718" alt="composer-bg"
src="https://github.com/user-attachments/assets/4b0f7f69-722a-438a-b4e9-0165ae8865a6"
/>

- update turn interrupted to be more human readable
<img width="648" height="170" alt="CleanShot 2025-08-06 at 22 44 47@2x"
src="https://github.com/user-attachments/assets/8d35e53a-bbfa-48e7-8612-c280a54e01dd"
/>

## Testing
- `cargo test --all-features` *(fails: `let` expressions in
`core/src/client.rs` require newer rustc)*
- `just fix` *(fails: `let` expressions in `core/src/client.rs` require
newer rustc)*

------
https://chatgpt.com/codex/tasks/task_i_68941f32c1008322bbcc39ee1d29a526
2025-08-07 00:46:45 -07:00
aibrahim-oai
04b40ac179 Move used tokens next to the hints (#1930)
Before:

<img width="341" height="58" alt="image"
src="https://github.com/user-attachments/assets/3b209e42-1157-4f7b-8385-825c865969e8"
/>

After:

<img width="490" height="53" alt="image"
src="https://github.com/user-attachments/assets/5d99b9bc-6ac2-4748-b62c-c0c3217622c2"
/>
2025-08-07 00:45:47 -07:00
easong-openai
4e29c4afe4 Add a UI hint when you press @ (#1903)
This will make @ more discoverable (even though it is currently not
super useful, IMO it should be used to bring files into context from
outside CWD)

---------

Co-authored-by: Gabriel Peal <gpeal@users.noreply.github.com>
2025-08-07 07:41:48 +00:00
Michael Bolin
cd5f9074af feat: add /tmp by default (#1919)
Replaces the `include_default_writable_roots` option on
`sandbox_workspace_write` (that defaulted to `true`, which was slightly
weird/annoying) with `exclude_tmpdir_env_var`, which defaults to
`false`.

Though perhaps more importantly `/tmp` is now enabled by default as part
of `sandbox_mode = "workspace-write"`, though `exclude_slash_tmp =
false` can be used to disable this.
2025-08-07 00:17:00 -07:00
aibrahim-oai
fff2bb39f9 change todo (#1925)
<img width="746" height="135" alt="image"
src="https://github.com/user-attachments/assets/1605b2fb-aa3a-4337-b9e9-93f6ff1361c5"
/>


<img width="747" height="126" alt="image"
src="https://github.com/user-attachments/assets/6b4366bd-8548-4d29-8cfa-cd484d9a2359"
/>
2025-08-07 00:01:38 -07:00
aibrahim-oai
f15e0fe1df Ensure exec command end always emitted (#1908)
## Summary
- defer ExecCommandEnd emission until after sandbox resolution
- make sandbox error handler return final exec output and response
- align sandbox error stderr with response content and rename to
`final_output`
- replace unstable `let` chains in client command header logic

## Testing
- `just fmt`
- `just fix`
- `cargo test --all-features` *(fails: NotPresent in
core/tests/client.rs)*

------
https://chatgpt.com/codex/tasks/task_i_6893e63b0c408321a8e1ff2a052c4c51
2025-08-07 06:25:56 +00:00
ae
f0fe61c667 feat: use ctrl c in interrupt hint (#1926)
https://chatgpt.com/codex/tasks/task_i_689441c33e1c832c85ceda166dab5d33
2025-08-06 23:22:58 -07:00
ae
935ad5c6f2 feat: >_ (#1924) 2025-08-06 22:54:54 -07:00
aibrahim-oai
ec20e84d80 Change the UI of apply patch (#1907)
<img width="487" height="108" alt="image"
src="https://github.com/user-attachments/assets/3f6ffd56-36f6-40bc-b999-64279705416a"
/>

---------

Co-authored-by: Gabriel Peal <gpeal@users.noreply.github.com>
2025-08-07 05:25:41 +00:00
easong-openai
2098b40369 Scrollable slash commands (#1830)
Scrollable slash commands. Part 1 of the multi PR.
2025-08-06 21:23:09 -07:00
aibrahim-oai
4971d54ca7 Show timing and token counts in status indicator (#1909)
## Summary
- track start time and cumulative tokens in status indicator
- display dim "(Ns • N tokens • Ctrl z to interrupt)" text after
animated Working header
- propagate token usage updates to status indicator views



https://github.com/user-attachments/assets/b73210c1-1533-40b5-b6c2-3c640029fd54


## Testing
- `just fmt`
- `just fix` *(fails: let expressions in this position are unstable)*
- `cargo test --all-features` *(fails: let expressions in this position
are unstable)*

------
https://chatgpt.com/codex/tasks/task_i_6893ec0d74a883218b94005172d7bc4c
2025-08-06 21:20:09 -07:00
Gabriel Peal
8a990b5401 Migrate GitWarning to OnboardingScreen (#1915)
This paves the way to do per-directory approval settings
(https://github.com/openai/codex/pull/1912).

This also lets us pass in a Config/ChatWidgetArgs into onboarding which
can then mutate it and emit the ChatWidgetArgs it wants at the end which
may be modified by the said approval settings.

<img width="1180" height="428" alt="CleanShot 2025-08-06 at 19 30 55"
src="https://github.com/user-attachments/assets/4dcfda42-0f5e-4b6d-a16d-2597109cc31c"
/>
2025-08-06 22:39:07 -04:00
aibrahim-oai
a5e17cda6b Run command UI (#1897)
Edit how commands show:

<img width="243" height="119" alt="image"
src="https://github.com/user-attachments/assets/13d5608e-3b66-4b8d-8fe7-ce464310d85d"
/>
2025-08-07 00:10:59 +00:00
pap-openai
8a980399c5 fix cursor file name insert (#1896)
Cursor wasn't moving when inserting a file, resulting in being not at
the end of the filename when inserting the file.
This fixes it by moving the cursor to the end of the file + one trailing
space.


Example screenshot after selecting a file when typing `@`
<img width="823" height="268" alt="image"
src="https://github.com/user-attachments/assets/ec6e3741-e1ba-4752-89d2-11f14a2bd69f"
/>
2025-08-06 16:58:06 -07:00
72 changed files with 3560 additions and 1482 deletions

BIN
.github/codex-cli-login.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

BIN
.github/codex-cli-permissions.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

BIN
.github/codex-cli-splash.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

321
README.md
View File

@@ -1,11 +1,12 @@
<h1 align="center">OpenAI Codex CLI</h1>
<p align="center">Lightweight coding agent that runs in your terminal</p>
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install codex</code></p>
This is the home of the **Codex CLI**, which is a coding agent from OpenAI that runs locally on your computer. If you are looking for the _cloud-based agent_ from OpenAI, **Codex [Web]**, see <https://chatgpt.com/codex>.
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, see <a href="https://chatgpt.com/codex">chatgpt.com/codex</a>.</p>
<!-- ![Codex demo GIF using: codex "explain this codebase to me"](./.github/demo.gif) -->
<p align="center">
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="50%" />
</p>
---
@@ -14,22 +15,28 @@ This is the home of the **Codex CLI**, which is a coding agent from OpenAI that
<!-- Begin ToC -->
- [Experimental technology disclaimer](#experimental-technology-disclaimer)
- [Quickstart](#quickstart)
- [OpenAI API Users](#openai-api-users)
- [OpenAI Plus/Pro Users](#openai-pluspro-users)
- [Installing and running Codex CLI](#installing-and-running-codex-cli)
- [Updating](#updating)
- [Using Codex with your ChatGPT plan](#using-codex-with-your-chatgpt-plan)
- [Usage-based billing alternative: Use an OpenAI API key](#usage-based-billing-alternative-use-an-openai-api-key)
- [Choosing Codex's level of autonomy](#choosing-codexs-level-of-autonomy)
- [**1. Read/write**](#1-readwrite)
- [**2. Read-only**](#2-read-only)
- [**3. Advanced configuration**](#3-advanced-configuration)
- [Can I run without ANY approvals?](#can-i-run-without-any-approvals)
- [Fine-tuning in `config.toml`](#fine-tuning-in-configtoml)
- [Example prompts](#example-prompts)
- [Running with a prompt as input](#running-with-a-prompt-as-input)
- [Using Open Source Models](#using-open-source-models)
- [Why Codex?](#why-codex)
- [Security model & permissions](#security-model--permissions)
- [Platform sandboxing details](#platform-sandboxing-details)
- [Experimental technology disclaimer](#experimental-technology-disclaimer)
- [System requirements](#system-requirements)
- [CLI reference](#cli-reference)
- [Memory & project docs](#memory--project-docs)
- [Non-interactive / CI mode](#non-interactive--ci-mode)
- [Model Context Protocol (MCP)](#model-context-protocol-mcp)
- [Tracing / verbose logging](#tracing--verbose-logging)
- [Recipes](#recipes)
- [Installation](#installation)
- [DotSlash](#dotslash)
- [Configuration](#configuration)
- [FAQ](#faq)
@@ -54,55 +61,166 @@ This is the home of the **Codex CLI**, which is a coding agent from OpenAI that
---
## Experimental technology disclaimer
Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
- Bug reports
- Feature requests
- Pull requests
- Good vibes
Help us improve by filing issues or submitting PRs (see the section below for how to contribute)!
## Quickstart
### Installing and running Codex CLI
Install globally with your preferred package manager:
```shell
npm install -g @openai/codex # Alternatively: `brew install codex`
```
Or go to the [latest GitHub Release](https://github.com/openai/codex/releases/latest) and download the appropriate binary for your platform.
Then simply run `codex` to get started:
### OpenAI API Users
```shell
codex
```
Next, set your OpenAI API key as an environment variable:
### Updating
Upgrade an existing installation to the latest release:
```shell
codex update
```
The command checks for a newer version and will attempt to upgrade automatically if the CLI was installed via npm or Homebrew.
<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>
Each GitHub Release contains many executables, but in practice, you likely want one of these:
- macOS
- Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz`
- x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz`
- Linux
- x86_64: `codex-x86_64-unknown-linux-musl.tar.gz`
- arm64: `codex-aarch64-unknown-linux-musl.tar.gz`
Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it.
</details>
### Using Codex with your ChatGPT plan
<p align="center">
<img src="./.github/codex-cli-login.png" alt="Codex CLI login" width="50%" />
</p>
After you run `codex` select Sign in with ChatGPT. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
> Important: If you've used the Codex CLI before, you'll need to follow these steps to migrate from usage-based billing with your API key:
>
> 1. Update the CLI with `codex update` and ensure `codex --version` is greater than 0.13
> 2. Ensure that there is no `OPENAI_API_KEY` environment variable set. (Check that `env | grep 'OPENAI_API_KEY'` returns empty)
> 3. Run `codex login` again
If you encounter problems with the login flow, please comment on [this issue](https://github.com/openai/codex/issues/1243).
### 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 by setting it as an environment variable:
```shell
export OPENAI_API_KEY="your-api-key-here"
```
> [!NOTE]
> This command sets the key only for your current terminal session. You can add the `export` line to your shell's configuration file (e.g., `~/.zshrc`), but we recommend setting it for the session.
> Note: This command only sets the key for your current terminal session, which we recommend. To set it for all future sessions, you can also add the `export` line to your shell's configuration file (e.g., `~/.zshrc`).
### OpenAI Plus/Pro Users
### Choosing Codex's level of autonomy
If you have a paid OpenAI account, run the following to start the login process:
We always recommend running Codex in its default sandbox that gives you strong guardrails around what the agent can do. The default sandbox prevents it from editing files outside its workspace, or from accessing the network.
```
codex login
When you launch Codex in a new folder, it detects whether the folder is version controlled and recommends one of two levels of autonomy:
#### **1. Read/write**
- Codex can run commands and write files in the workspace without approval.
- To write files in other folders, access network, update git or perform other actions protected by the sandbox, Codex will need your permission.
- By default, the workspace includes the current directory, as well as temporary directories like `/tmp`. You can see what directories are in the workspace with the `/status` command. See the docs for how to customize this behavior.
- Advanced: You can manually specify this configuration by running `codex --sandbox workspace-write --ask-for-approval on-request`
- This is the recommended default for version-controlled folders.
#### **2. Read-only**
- Codex can run read-only commands without approval.
- To edit files, access network, or perform other actions protected by the sandbox, Codex will need your permission.
- Advanced: You can manually specify this configuration by running `codex --sandbox read-only --ask-for-approval on-request`
- This is the recommended default non-version-controlled folders.
#### **3. Advanced configuration**
Codex gives you fine-grained control over the sandbox with the `--sandbox` option, and over when it requests approval with the `--ask-for-approval` option. Run `codex help` for more on these options.
#### Can I run without ANY approvals?
Yes, run codex non-interactively with `--ask-for-approval never`. This option works with all `--sandbox` options, so you still have full control over Codex's level of autonomy. It will make its best attempt with whatever contrainsts you provide. For example:
- Use `codex --ask-for-approval never --sandbox read-only` when you are running many agents to answer questions in parallel in the same workspace.
- Use `codex --ask-for-approval never --sandbox workspace-write` when you want the agent to non-interactively take time to produce the best outcome, with strong guardrails around its behavior.
- Use `codex --ask-for-approval never --sandbox danger-full-access` to dangerously give the agent full autonomy. Because this disables important safety mechanisms, we recommend against using this unless running Codex in an isolated environment.
#### Fine-tuning in `config.toml`
```toml
# approval mode
approval_policy = "untrusted"
sandbox_mode = "read-only"
# full-auto mode
approval_policy = "on-request"
sandbox_mode = "workspace-write"
# Optional: allow network in workspace-write mode
[sandbox_workspace_write]
network_access = true
```
If you complete the process successfully, you should have a `~/.codex/auth.json` file that contains the credentials that Codex will use.
You can also save presets as **profiles**:
To verify whether you are currently logged in, run:
```toml
[profiles.full_auto]
approval_policy = "on-request"
sandbox_mode = "workspace-write"
```
codex login status
[profiles.readonly_quiet]
approval_policy = "never"
sandbox_mode = "read-only"
```
If you encounter problems with the login flow, please comment on <https://github.com/openai/codex/issues/1243>.
### Example prompts
Below are a few bite-size examples you can copy-paste. Replace the text in quotes with your own task. See the [prompting guide](https://github.com/openai/codex/blob/main/codex-cli/examples/prompting_guide.md) for more tips and usage patterns.
| ✨ | What you type | What happens |
| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 1 | `codex "Refactor the Dashboard component to React Hooks"` | Codex rewrites the class component, runs `npm test`, and shows the diff. |
| 2 | `codex "Generate SQL migrations for adding a users table"` | Infers your ORM, creates migration files, and runs them in a sandboxed DB. |
| 3 | `codex "Write unit tests for utils/date.ts"` | Generates tests, executes them, and iterates until they pass. |
| 4 | `codex "Bulk-rename *.jpeg -> *.jpg with git mv"` | Safely renames files and updates imports/usages. |
| 5 | `codex "Explain what this regex does: ^(?=.*[A-Z]).{8,}$"` | Outputs a step-by-step human explanation. |
| 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. |
| 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. |
## Running with a prompt as input
You can also run Codex CLI with a prompt as input:
```shell
codex "explain this codebase to me"
```
```shell
codex --full-auto "create the fanciest todo-list app"
```
That's it - Codex will scaffold a file, run it inside a sandbox, install any
missing dependencies, and show you the live result. Approve the changes and
they'll be committed to your working directory.
## Using Open Source Models
<details>
<summary><strong>Use <code>--profile</code> to use other models</strong></summary>
@@ -163,31 +281,6 @@ model = "mistral"
This way, you can specify one command-line argument (.e.g., `--profile o3`, `--profile mistral`) to override multiple settings together.
</details>
<br />
Run interactively:
```shell
codex
```
Or, run with a prompt as input (and optionally in `Full Auto` mode):
```shell
codex "explain this codebase to me"
```
```shell
codex --full-auto "create the fanciest todo-list app"
```
That's it - Codex will scaffold a file, run it inside a sandbox, install any
missing dependencies, and show you the live result. Approve the changes and
they'll be committed to your working directory.
---
## Using Open Source Models
Codex can run fully locally against an OpenAI-compatible OSS host (like Ollama) using the `--oss` flag:
@@ -222,44 +315,6 @@ base_url = "http://my-ollama.example.com:11434/v1"
---
## Why Codex?
Codex CLI is built for developers who already **live in the terminal** and want
ChatGPT-level reasoning **plus** the power to actually run code, manipulate
files, and iterate - all under version control. In short, it's _chat-driven
development_ that understands and executes your repo.
- **Zero setup** - bring your OpenAI API key and it just works!
- **Full auto-approval, while safe + secure** by running network-disabled and directory-sandboxed
- **Multimodal** - pass in screenshots or diagrams to implement features ✨
And it's **fully open-source** so you can see and contribute to how it develops!
---
## Security model & permissions
Codex lets you decide _how much autonomy_ you want to grant the agent. The following options can be configured independently:
- [`approval_policy`](./codex-rs/config.md#approval_policy) determines when you should be prompted to approve whether Codex can execute a command
- [`sandbox`](./codex-rs/config.md#sandbox) determines the _sandbox policy_ that Codex uses to execute untrusted commands
By default, Codex runs with `--ask-for-approval untrusted` and `--sandbox read-only`, which means that:
- The user is prompted to approve every command not on the set of "trusted" commands built into Codex (`cat`, `ls`, etc.)
- Approved commands are run outside of a sandbox because user approval implies "trust," in this case.
Running Codex with the `--full-auto` convenience flag changes the configuration to `--ask-for-approval on-failure` and `--sandbox workspace-write`, which means that:
- Codex does not initially ask for user approval before running an individual command.
- Though when it runs a command, it is run under a sandbox in which:
- It can read any file on the system.
- It can only write files under the current directory (or the directory specified via `--cd`).
- Network requests are completely disabled.
- Only if the command exits with a non-zero exit code will it ask the user for approval. If granted, it will re-attempt the command outside of the sandbox. (A common case is when Codex cannot `npm install` a dependency because that requires network access.)
Again, these two options can be configured independently. For example, if you want Codex to perform an "exploration" where you are happy for it to read anything it wants but you never want to be prompted, you could run Codex with `--ask-for-approval never` and `--sandbox read-only`.
### Platform sandboxing details
The mechanism Codex uses to implement the sandbox policy depends on your OS:
@@ -271,6 +326,19 @@ Note that when running Linux in a containerized environment such as Docker, sand
---
## Experimental technology disclaimer
Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
- Bug reports
- Feature requests
- Pull requests
- Good vibes
Help us improve by filing issues or submitting PRs (see the section below for how to contribute)!
---
## System requirements
| Requirement | Details |
@@ -283,11 +351,12 @@ Note that when running Linux in a containerized environment such as Docker, sand
## CLI reference
| Command | Purpose | Example |
| ------------------ | ---------------------------------- | ------------------------------- |
| `codex` | Interactive TUI | `codex` |
| `codex "..."` | Initial prompt for interactive TUI | `codex "fix lint errors"` |
| `codex exec "..."` | Non-interactive "automation mode" | `codex exec "explain utils.ts"` |
| Command | Purpose | Example |
| ------------------ | ------------------------------------- | ------------------------------- |
| `codex` | Interactive TUI | `codex` |
| `codex "..."` | Initial prompt for interactive TUI | `codex "fix lint errors"` |
| `codex exec "..."` | Non-interactive "automation mode" | `codex exec "explain utils.ts"` |
| `codex update` | Check for updates and upgrade the CLI | `codex update` |
Key flags: `--model/-m`, `--ask-for-approval/-a`.
@@ -346,52 +415,6 @@ See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env
---
## Recipes
Below are a few bite-size examples you can copy-paste. Replace the text in quotes with your own task. See the [prompting guide](https://github.com/openai/codex/blob/main/codex-cli/examples/prompting_guide.md) for more tips and usage patterns.
| ✨ | What you type | What happens |
| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 1 | `codex "Refactor the Dashboard component to React Hooks"` | Codex rewrites the class component, runs `npm test`, and shows the diff. |
| 2 | `codex "Generate SQL migrations for adding a users table"` | Infers your ORM, creates migration files, and runs them in a sandboxed DB. |
| 3 | `codex "Write unit tests for utils/date.ts"` | Generates tests, executes them, and iterates until they pass. |
| 4 | `codex "Bulk-rename *.jpeg -> *.jpg with git mv"` | Safely renames files and updates imports/usages. |
| 5 | `codex "Explain what this regex does: ^(?=.*[A-Z]).{8,}$"` | Outputs a step-by-step human explanation. |
| 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. |
| 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. |
---
## Installation
<details open>
<summary><strong>Install Codex CLI using your preferred package manager.</strong></summary>
From `brew` (recommended, downloads only the binary for your platform):
```bash
brew install codex
```
From `npm` (generally more readily available, but downloads binaries for all supported platforms):
```bash
npm i -g @openai/codex
```
Or go to the [latest GitHub Release](https://github.com/openai/codex/releases/latest) and download the appropriate binary for your platform.
Admittedly, each GitHub Release contains many executables, but in practice, you likely want one of these:
- macOS
- Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz`
- x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz`
- Linux
- x86_64: `codex-x86_64-unknown-linux-musl.tar.gz`
- arm64: `codex-aarch64-unknown-linux-musl.tar.gz`
Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it.
### DotSlash
The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file for the Codex CLI named `codex`. Using a DotSlash file makes it possible to make a lightweight commit to source control to ensure all contributors use the same version of an executable, regardless of what platform they use for development.

View File

@@ -854,7 +854,7 @@ export default function TerminalChatInput({
/>
) : (
<Text dimColor>
ctrl+c to exit | "/" to see commands | enter to send
Ctrl+C to exit | "/" to see commands | Enter to send
{contextLeftPercent > 25 && (
<>
{" — "}

View File

@@ -96,7 +96,7 @@ export default function HelpOverlay({
</Box>
<Box paddingX={1}>
<Text dimColor>esc or q to close</Text>
<Text dimColor>Esc or q to close</Text>
</Box>
</Box>
);

View File

@@ -68,7 +68,7 @@ export function WaitingForAuth(): JSX.Element {
<Spinner type="ball" />
<Text>
{" "}
Waiting for authentication <Text dimColor>ctrl + c to quit</Text>
Waiting for authentication <Text dimColor>Ctrl + C to quit</Text>
</Text>
</Box>
);

69
codex-rs/Cargo.lock generated
View File

@@ -658,10 +658,16 @@ dependencies = [
name = "codex-common"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"codex-core",
"reqwest",
"serde",
"serde_json",
"tokio",
"toml 0.9.4",
"tracing",
]
[[package]]
@@ -708,6 +714,7 @@ dependencies = [
"tokio-test",
"tokio-util",
"toml 0.9.4",
"toml_edit 0.23.3",
"tracing",
"tree-sitter",
"tree-sitter-bash",
@@ -792,11 +799,14 @@ dependencies = [
name = "codex-login"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"chrono",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.12",
"tokio",
]
@@ -873,6 +883,7 @@ dependencies = [
"codex-ollama",
"color-eyre",
"crossterm",
"diffy",
"image",
"insta",
"lazy_static",
@@ -883,7 +894,6 @@ dependencies = [
"ratatui",
"ratatui-image",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"shlex",
@@ -1255,6 +1265,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "diffy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291"
dependencies = [
"nu-ansi-term 0.50.1",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1493,7 +1512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -1573,7 +1592,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2356,7 +2375,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2832,6 +2851,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
@@ -3251,7 +3279,7 @@ version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
"toml_edit 0.22.27",
]
[[package]]
@@ -3740,7 +3768,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3753,7 +3781,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -4519,7 +4547,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4778,7 +4806,7 @@ dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
"toml_edit 0.22.27",
]
[[package]]
@@ -4828,10 +4856,23 @@ dependencies = [
]
[[package]]
name = "toml_parser"
version = "1.0.1"
name = "toml_edit"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e"
dependencies = [
"indexmap 2.10.0",
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
dependencies = [
"winnow",
]
@@ -4960,7 +5001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"matchers",
"nu-ansi-term",
"nu-ansi-term 0.46.0",
"once_cell",
"regex",
"sharded-slab",
@@ -5378,7 +5419,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -1,3 +1,4 @@
use codex_login::CodexAuth;
use std::path::Path;
use std::sync::LazyLock;
use std::sync::RwLock;
@@ -18,7 +19,7 @@ pub fn set_chatgpt_token_data(value: TokenData) {
/// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
let auth = codex_login::load_auth(codex_home, true)?;
let auth = CodexAuth::from_codex_home(codex_home)?;
if let Some(auth) = auth {
let token_data = auth.get_token_data().await?;
set_chatgpt_token_data(token_data);

View File

@@ -20,7 +20,7 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
codex-arg0 = { path = "../arg0" }
codex-chatgpt = { path = "../chatgpt" }
codex-common = { path = "../common", features = ["cli"] }
codex-common = { path = "../common", features = ["cli", "updates"] }
codex-core = { path = "../core" }
codex-exec = { path = "../exec" }
codex-login = { path = "../login" }

View File

@@ -4,10 +4,11 @@ use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_login::load_auth;
use codex_login::login_with_api_key;
use codex_login::login_with_chatgpt;
use codex_login::logout;
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
@@ -46,11 +47,11 @@ pub async fn run_login_with_api_key(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match load_auth(&config.codex_home, true) {
match CodexAuth::from_codex_home(&config.codex_home) {
Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => {
if let Some(api_key) = auth.api_key.as_deref() {
eprintln!("Logged in using an API key - {}", safe_format_key(api_key));
AuthMode::ApiKey => match auth.get_token().await {
Ok(api_key) => {
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR) {
if env_api_key == api_key {
@@ -59,11 +60,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
);
}
}
} else {
eprintln!("Logged in using an API key");
std::process::exit(0);
}
std::process::exit(0);
}
Err(e) => {
eprintln!("Unexpected error retrieving API key: {e}");
std::process::exit(1);
}
},
AuthMode::ChatGPT => {
eprintln!("Logged in using ChatGPT");
std::process::exit(0);
@@ -80,6 +83,25 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
}
}
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match logout(&config.codex_home) {
Ok(true) => {
eprintln!("Successfully logged out");
std::process::exit(0);
}
Ok(false) => {
eprintln!("Not logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging out: {e}");
std::process::exit(1);
}
}
}
fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
let cli_overrides = match cli_config_overrides.parse_overrides() {
Ok(v) => v,

View File

@@ -10,8 +10,15 @@ use codex_cli::SeatbeltCommand;
use codex_cli::login::run_login_status;
use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_logout;
use codex_cli::proto;
use codex_common::CliConfigOverrides;
use codex_common::updates::check_for_update;
use codex_common::updates::get_upgrade_version;
#[cfg(not(debug_assertions))]
use codex_core::config::Config;
#[cfg(not(debug_assertions))]
use codex_core::config::ConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli;
use std::path::PathBuf;
@@ -48,6 +55,9 @@ enum Subcommand {
/// Manage login.
Login(LoginCommand),
/// Remove stored authentication credentials.
Logout(LogoutCommand),
/// Experimental: run Codex as an MCP server.
Mcp,
@@ -64,6 +74,9 @@ enum Subcommand {
/// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree.
#[clap(visible_alias = "a")]
Apply(ApplyCommand),
/// Check for a newer Codex release and upgrade automatically when possible.
Update,
}
#[derive(Debug, Parser)]
@@ -106,6 +119,12 @@ enum LoginSubcommand {
Status,
}
#[derive(Debug, Parser)]
struct LogoutCommand {
#[clap(skip)]
config_overrides: CliConfigOverrides,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
@@ -147,6 +166,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
}
}
}
Some(Subcommand::Logout(mut logout_cli)) => {
prepend_config_flags(&mut logout_cli.config_overrides, cli.config_overrides);
run_logout(logout_cli.config_overrides).await;
}
Some(Subcommand::Proto(mut proto_cli)) => {
prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides);
proto::run_main(proto_cli).await?;
@@ -176,6 +199,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides);
run_apply_command(apply_cli, None).await?;
}
Some(Subcommand::Update) => {
run_update().await?;
}
}
Ok(())
@@ -197,3 +223,88 @@ fn print_completion(cmd: CompletionCommand) {
let name = "codex";
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
}
#[cfg(not(debug_assertions))]
async fn run_update() -> anyhow::Result<()> {
let overrides = ConfigOverrides {
model: None,
cwd: None,
approval_policy: None,
sandbox_mode: None,
model_provider: None,
config_profile: None,
codex_linux_sandbox_exe: None,
base_instructions: None,
include_plan_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
};
let config = Config::load_with_cli_overrides(Vec::new(), overrides)?;
let version_file = config.codex_home.join("version.json");
if let Err(e) = check_for_update(&version_file).await {
#[allow(clippy::print_stderr)]
eprintln!("Failed to check for updates: {e}");
}
let current_version = env!("CARGO_PKG_VERSION");
if let Some(latest_version) = get_upgrade_version(&config) {
println!("Current version: {current_version}");
println!("Latest version: {latest_version}");
let exe = std::env::current_exe()?;
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
if managed_by_npm {
println!("Updating via npm...");
match Command::new("npm")
.args(["install", "-g", "@openai/codex@latest"])
.status()
{
Ok(status) if status.success() => {
println!("Codex updated successfully.");
}
Ok(status) => {
println!(
"`npm install` exited with status {status}. Run `npm install -g @openai/codex@latest` manually if needed."
);
}
Err(err) => {
println!(
"Failed to run npm: {err}. Run `npm install -g @openai/codex@latest` manually."
);
}
}
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
println!("Updating via Homebrew...");
match Command::new("brew").args(["upgrade", "codex"]).status() {
Ok(status) if status.success() => {
println!("Codex updated successfully.");
}
Ok(status) => {
println!(
"`brew upgrade` exited with status {status}. Run `brew upgrade codex` manually if needed."
);
}
Err(err) => {
println!("Failed to run Homebrew: {err}. Run `brew upgrade codex` manually.");
}
}
} else {
println!(
"See https://github.com/openai/codex/releases/latest for the latest releases and installation options."
);
}
} else {
println!("Codex {current_version} is up to date.");
}
Ok(())
}
#[cfg(debug_assertions)]
async fn run_update() -> anyhow::Result<()> {
println!("Update checking is disabled in debug builds.");
Ok(())
}

View File

@@ -9,7 +9,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::Submission;
use codex_core::util::notify_on_sigint;
use codex_login::load_auth;
use codex_login::CodexAuth;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -36,7 +36,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
let auth = load_auth(&config.codex_home, true)?;
let auth = CodexAuth::from_codex_home(&config.codex_home)?;
let ctrl_c = notify_on_sigint();
let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
let codex = Arc::new(codex);

View File

@@ -7,13 +7,20 @@ version = { workspace = true }
workspace = true
[dependencies]
anyhow = { version = "1", optional = true }
chrono = { version = "0.4", features = ["serde"], optional = true }
clap = { version = "4", features = ["derive", "wrap_help"], optional = true }
codex-core = { path = "../core" }
serde = { version = "1", optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tokio = { version = "1", features = ["fs"], optional = true }
toml = { version = "0.9", optional = true }
tracing = "0.1.41"
[features]
# Separate feature so that `clap` is not a mandatory dependency.
cli = ["clap", "serde", "toml"]
elapsed = []
sandbox_summary = []
updates = ["anyhow", "chrono", "reqwest", "serde", "serde_json", "tokio"]

View File

@@ -0,0 +1,177 @@
/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
///
/// Returns the indices (character positions) of the matched characters in the
/// ORIGINAL `haystack` string and a score where smaller is better.
///
/// Unicode correctness: we perform the match on a lowercased copy of the
/// haystack and needle but maintain a mapping from each character in the
/// lowercased haystack back to the original character index in `haystack`.
/// This ensures the returned indices can be safely used with
/// `str::chars().enumerate()` consumers for highlighting, even when
/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇).
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
if needle.is_empty() {
return Some((Vec::new(), i32::MAX));
}
let mut lowered_chars: Vec<char> = Vec::new();
let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
for (orig_idx, ch) in haystack.chars().enumerate() {
for lc in ch.to_lowercase() {
lowered_chars.push(lc);
lowered_to_orig_char_idx.push(orig_idx);
}
}
let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
let mut last_lower_pos: Option<usize> = None;
let mut cur = 0usize;
for &nc in lowered_needle.iter() {
let mut found_at: Option<usize> = None;
while cur < lowered_chars.len() {
if lowered_chars[cur] == nc {
found_at = Some(cur);
cur += 1;
break;
}
cur += 1;
}
let pos = found_at?;
result_orig_indices.push(lowered_to_orig_char_idx[pos]);
last_lower_pos = Some(pos);
}
let first_lower_pos = if result_orig_indices.is_empty() {
0usize
} else {
let target_orig = result_orig_indices[0];
lowered_to_orig_char_idx
.iter()
.position(|&oi| oi == target_orig)
.unwrap_or(0)
};
// last defaults to first for single-hit; score = extra span between first/last hit
// minus needle len (≥0).
// Strongly reward prefix matches by subtracting 100 when the first hit is at index 0.
let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos);
let window =
(last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
let mut score = window.max(0);
if first_lower_pos == 0 {
score -= 100;
}
result_orig_indices.sort_unstable();
result_orig_indices.dedup();
Some((result_orig_indices, score))
}
/// Convenience wrapper to get only the indices for a fuzzy match.
pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
fuzzy_match(haystack, needle).map(|(mut idx, _)| {
idx.sort_unstable();
idx.dedup();
idx
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_basic_indices() {
let (idx, score) = match fuzzy_match("hello", "hl") {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0, 2]);
// 'h' at 0, 'l' at 2 -> window 1; start-of-string bonus applies (-100)
assert_eq!(score, -99);
}
#[test]
fn unicode_dotted_i_istanbul_highlighting() {
let (idx, score) = match fuzzy_match("İstanbul", "is") {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0, 1]);
// Matches at lowered positions 0 and 2 -> window 1; start-of-string bonus applies
assert_eq!(score, -99);
}
#[test]
fn unicode_german_sharp_s_casefold() {
assert!(fuzzy_match("straße", "strasse").is_none());
}
#[test]
fn prefer_contiguous_match_over_spread() {
let (_idx_a, score_a) = match fuzzy_match("abc", "abc") {
Some(v) => v,
None => panic!("expected a match"),
};
let (_idx_b, score_b) = match fuzzy_match("a-b-c", "abc") {
Some(v) => v,
None => panic!("expected a match"),
};
// Contiguous window -> 0; start-of-string bonus -> -100
assert_eq!(score_a, -100);
// Spread over 5 chars for 3-letter needle -> window 2; with bonus -> -98
assert_eq!(score_b, -98);
assert!(score_a < score_b);
}
#[test]
fn start_of_string_bonus_applies() {
let (_idx_a, score_a) = match fuzzy_match("file_name", "file") {
Some(v) => v,
None => panic!("expected a match"),
};
let (_idx_b, score_b) = match fuzzy_match("my_file_name", "file") {
Some(v) => v,
None => panic!("expected a match"),
};
// Start-of-string contiguous -> window 0; bonus -> -100
assert_eq!(score_a, -100);
// Non-prefix contiguous -> window 0; no bonus -> 0
assert_eq!(score_b, 0);
assert!(score_a < score_b);
}
#[test]
fn empty_needle_matches_with_max_score_and_no_indices() {
let (idx, score) = match fuzzy_match("anything", "") {
Some(v) => v,
None => panic!("empty needle should match"),
};
assert!(idx.is_empty());
assert_eq!(score, i32::MAX);
}
#[test]
fn case_insensitive_matching_basic() {
let (idx, score) = match fuzzy_match("FooBar", "foO") {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0, 1, 2]);
// Contiguous prefix match (case-insensitive) -> window 0 with bonus
assert_eq!(score, -100);
}
#[test]
fn indices_are_deduped_for_multichar_lowercase_expansion() {
let needle = "\u{0069}\u{0307}"; // "i" + combining dot above
let (idx, score) = match fuzzy_match("İ", needle) {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0]);
// Lowercasing 'İ' expands to two chars; contiguous prefix -> window 0 with bonus
assert_eq!(score, -100);
}
}

View File

@@ -27,3 +27,8 @@ pub use sandbox_summary::summarize_sandbox_policy;
mod config_summary;
pub use config_summary::create_config_summary_entries;
// Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
pub mod fuzzy_match;
#[cfg(any(test, feature = "updates"))]
pub mod updates;

View File

@@ -7,22 +7,26 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
include_default_writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => {
let mut summary = "workspace-write".to_string();
if !writable_roots.is_empty() {
summary.push_str(&format!(
" [{}]",
writable_roots
.iter()
.map(|p| p.to_string_lossy())
.collect::<Vec<_>>()
.join(", ")
));
let mut writable_entries = Vec::<String>::new();
writable_entries.push("workdir".to_string());
if !*exclude_slash_tmp {
writable_entries.push("/tmp".to_string());
}
if !*include_default_writable_roots {
summary.push_str(" (exact writable roots)");
if !*exclude_tmpdir_env_var {
writable_entries.push("$TMPDIR".to_string());
}
writable_entries.extend(
writable_roots
.iter()
.map(|p| p.to_string_lossy().to_string()),
);
summary.push_str(&format!(" [{}]", writable_entries.join(", ")));
if *network_access {
summary.push_str(" (network access enabled)");
}

View File

@@ -1,15 +1,15 @@
#![cfg(any(not(debug_assertions), test))]
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use codex_core::config::Config;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use tracing::error;
use codex_core::config::Config;
/// Returns the latest available version string if it is newer than the current
/// one, otherwise `None`.
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
let info = read_version_info(&version_file).ok();
@@ -18,13 +18,11 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
None => true,
Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
} {
// Refresh the cached latest version in the background so TUI startup
// isnt blocked by a network call. The UI reads the previously cached
// value (if any) for this run; the next run shows the banner if needed.
// Refresh in the background; callers can use the cached value for this run.
tokio::spawn(async move {
check_for_update(&version_file)
.await
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
.inspect_err(|e| error!("Failed to update version: {e}"))
});
}
@@ -62,7 +60,8 @@ fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
Ok(serde_json::from_str(&contents)?)
}
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
/// Fetches the latest release info and updates the on-disk cache file.
pub async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
let ReleaseInfo {
tag_name: latest_tag_name,
} = reqwest::Client::new()

View File

@@ -275,9 +275,12 @@ sandbox_mode = "workspace-write"
# Extra settings that only apply when `sandbox = "workspace-write"`.
[sandbox_workspace_write]
# By default, only the cwd for the Codex session will be writable (and $TMPDIR
# on macOS), but you can specify additional writable folders in this array.
writable_roots = ["/tmp"]
# By default, the cwd for the Codex session will be writable as well as $TMPDIR
# (if set) and /tmp (if it exists). Setting the respective options to `true`
# will override those defaults.
exclude_tmpdir_env_var = false
exclude_slash_tmp = false
# Allow the command being run inside the sandbox to make outbound network
# requests. Disabled by default.
network_access = false
@@ -336,12 +339,11 @@ disable_response_storage = true
## shell_environment_policy
Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it passes **only a minimal core subset** of your environment to those subprocesses to avoid leaking credentials. You can tune this behavior via the **`shell_environment_policy`** block in
`config.toml`:
Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`:
```toml
[shell_environment_policy]
# inherit can be "core" (default), "all", or "none"
# inherit can be "all" (default), "core", or "none"
inherit = "core"
# set to true to *skip* the filter for `"*KEY*"` and `"*TOKEN*"`
ignore_default_excludes = false
@@ -355,7 +357,7 @@ include_only = ["PATH", "HOME"]
| Field | Type | Default | Description |
| ------------------------- | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `inherit` | string | `core` | Starting template for the environment:<br>`core` (`HOME`, `PATH`, `USER`, …), `all` (clone full parent env), or `none` (start empty). |
| `inherit` | string | `all` | Starting template for the environment:<br>`all` (clone full parent env), `core` (`HOME`, `PATH`, `USER`, …), or `none` (start empty). |
| `ignore_default_excludes` | boolean | `false` | When `false`, Codex removes any var whose **name** contains `KEY`, `SECRET`, or `TOKEN` (case-insensitive) before other rules run. |
| `exclude` | array&lt;string&gt; | `[]` | Case-insensitive glob patterns to drop after the default filter.<br>Examples: `"AWS_*"`, `"AZURE_*"`. |
| `set` | table&lt;string,string&gt; | `{}` | Explicit key/value overrides or additions always win over inherited values. |

View File

@@ -36,6 +36,7 @@ sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.12"
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
tokio = { version = "1", features = [
@@ -47,6 +48,7 @@ tokio = { version = "1", features = [
] }
tokio-util = "0.7.14"
toml = "0.9.4"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"

View File

@@ -1,69 +1,273 @@
You are operating as and within the Codex CLI, an open-source, terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
Your capabilities:
- Receive user prompts, project context, and files.
- Stream responses and emit function calls (e.g., shell commands, code edits).
- Run commands, like apply_patch, and manage user approvals based on policy.
- Work inside a workspace with sandboxing instructions specified by the policy described in (## Sandbox environment and approval instructions)
- Receive user prompts and other context provided by the harness, such as files in the workspace.
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
## General guidelines
As a deployed coding agent, please continue working on the user's task until their query is resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the task is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information. Do NOT guess or make up an answer.
# How you work
After a user sends their first message, you should immediately provide a brief message acknowledging their request to set the tone and expectation of future work to be done (no more than 8-10 words). This should be done before performing work like exploring the codebase, writing or reading files, or other tool calls needed to complete the task. Use a natural, collaborative tone similar to how a teammate would receive a task during a pair programming session.
## Personality
Please resolve the user's task by editing the code files in your current code execution session. Your session allows for you to modify and run code. The repo(s) are already cloned in your working directory, and you must fully solve the problem for your answer to be considered correct.
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
### Task execution
You MUST adhere to the following criteria when executing the task:
## Responsiveness
### Preamble messages
Before making tool calls, send a brief preamble to the user explaining what youre about to do. When sending preamble messages, follow these principles and examples:
- **Logically group related actions**: if youre about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
- **Keep it concise**: be no more than 1-2 sentences (812 words for quick updates).
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with whats been done so far and create a sense of momentum and clarity for the user to understand your next actions.
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
**Examples:**
- “Ive explored the repo; now checking the API route definitions.”
- “Next, Ill patch the config and update the related tests.”
- “Im about to scaffold the CLI commands and helper functions.”
- “Ok cool, so Ive wrapped my head around the repo. Now digging into the API routes.”
- “Configs looking tidy. Next up is patching helpers to keep things in sync.”
- “Finished poking at the DB gateway. I will now chase down error handling.”
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
- “Spotted a clever caching util; now hunting where it gets used.”
**Avoiding a preamble for every trivial read (e.g., `cat` a single file) unless its part of a larger grouped action.
- Jumping straight into tool calls without explaining whats about to happen.
- Writing overly long or speculative preambles — focus on immediate, tangible next steps.
## Planning
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. Note that plans are not for padding out simple work with filler steps or stating the obvious. Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
Use a plan when:
- The task is non-trivial and will require multiple actions over a long time horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
- You want intermediate checkpoints for feedback and validation.
- When the user asked you to do more than one thing in a single prompt
- The user has asked you to use the plan tool (aka "TODOs")
- You generate additional steps while working, and plan to do them before yielding to the user
Skip a plan when:
- The task is simple and direct.
- Breaking it down would only produce literal or trivial steps.
Planning steps are called "steps" in the tool, but really they're more like tasks or TODOs. As such they should be very concise descriptions of non-obvious work that an engineer might do like "Write the API spec", then "Update the backend", then "Implement the frontend". On the other hand, it's obvious that you'll usually have to "Explore the codebase" or "Implement the changes", so those are not worth tracking in your plan.
It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
### Examples
**High-quality plans**
Example 1:
1. Add CLI entry with file args
2. Parse Markdown via CommonMark library
3. Apply semantic HTML template
4. Handle code blocks, images, links
5. Add error handling for invalid files
Example 2:
1. Define CSS variables for colors
2. Add toggle with localStorage state
3. Refactor components to use variables
4. Verify all views for readability
5. Add smooth theme-change transition
Example 3:
1. Set up Node.js + WebSocket server
2. Add join/leave broadcast events
3. Implement messaging with timestamps
4. Add usernames + mention highlighting
5. Persist messages in lightweight DB
6. Add typing indicators + unread count
**Low-quality plans**
Example 1:
1. Create CLI tool
2. Add Markdown parser
3. Convert to HTML
Example 2:
1. Add dark mode toggle
2. Save preference
3. Make styles look good
Example 3:
1. Create single-file HTML game
2. Run quick sanity check
3. Summarize usage instructions
If you need to write a plan, only write high quality plans, not low quality ones.
## Task execution
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
You MUST adhere to the following criteria when solving queries:
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
- Analyzing code for vulnerabilities is allowed.
- Showing user code and tool call details is allowed.
- User instructions may overwrite the _CODING GUIDELINES_ section in this developer message.
- `user_instructions` are not part of the user's request, but guidance for how to complete the task.
- Do not cite `user_instructions` back to the user unless a specific piece is relevant.
- Do not use \`ls -R\`, \`find\`, or \`grep\` - these are slow in large repos. Use \`rg\` and \`rg --files\`.
- Use the \`apply_patch\` shell command to edit files: {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
- If completing the user's task requires writing or modifying files:
- Your code and final answer should follow these _CODING GUIDELINES_:
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
- Avoid unneeded complexity in your solution.
- Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
- Update documentation as necessary.
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
- Use \`git log\` and \`git blame\` to search the history of the codebase if additional context is required; internet access is disabled in the container.
- NEVER add copyright or license headers unless specifically requested.
- You do not need to \`git commit\` your changes; this will be done automatically for you.
- If there is a .pre-commit-config.yaml, use \`pre-commit run --files ...\` to check that your changes pass the pre- commit checks. However, do not fix pre-existing errors on lines you didn't touch.
- If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken.
- Once you finish coding, you must
- Check \`git status\` to sanity check your changes; revert any scratch files or changes.
- Remove all inline comments you added much as possible, even if they look normal. Check using \`git diff\`. Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
- Check if you accidentally add copyright or license headers. If so, remove them.
- Try to run pre-commit if it is available.
- For smaller tasks, describe in brief bullet points
- For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
- Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
- When your task involves writing or modifying files:
- Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using the `apply_patch` shell command. Instead, reference the file as already saved.
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
## Using the shell command `apply_patch` to edit files
`apply_patch` is a shell command for editing files. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
*** Begin Patch
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
- Avoid unneeded complexity in your solution.
- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
- Update documentation as necessary.
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
- Use `git log` and `git blame` to search the history of the codebase if additional context is required.
- NEVER add copyright or license headers unless specifically requested.
- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.
- Do not `git commit` your changes or create new git branches unless explicitly requested.
- Do not add inline comments within code unless explicitly requested.
- Do not use one-letter variable names unless explicitly requested.
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
## Testing your work
If the codebase has tests or the ability to build or run, you should use them to verify that your work is complete. Generally, your testing philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests, or where the patterns don't indicate so.
Once you're confident in correctness, use formatting commands to ensure that your code is well formatted. These commands can take time so you should run them on as precise a target as possible. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.
For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
## Sandbox and approvals
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
Filesystem sandboxing prevents you from editing files without user approval. The options are:
- *read-only*: You can only read files.
- *workspace-write*: You can read files. You can write to files in your workspace folder, but not outside it.
- *danger-full-access*: No filesystem sandboxing.
Network sandboxing prevents you from accessing network without approval. Options are
- *ON*
- *OFF*
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
- *untrusted*: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- *on-failure*: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- *on-request*: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- *never*: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (For all of these, you should weigh alternative paths that do not require approval.)
Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.
## Ambition vs. precision
For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.
If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.
You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.
## Sharing progress updates
For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next.
Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why.
The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.
## Presenting your work and final message
Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the users style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.
You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.
The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path.
If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If theres something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.
Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.
### Final answer structure and style guidelines
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
**Section Headers**
- Use only when they improve clarity — they are not mandatory for every answer.
- Choose descriptive names that fit the content
- Keep headers short (13 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
- Leave no blank line before the first bullet under a header.
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
**Bullets**
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
- Keep bullets to one line unless breaking for clarity is unavoidable.
- Group into short lists (46 bullets) ordered by importance.
- Use consistent keyword phrasing and formatting across sections.
**Monospace**
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
- Never mix monospace and bold markers; choose one based on whether its a keyword (`**`) or inline code/path (`` ` ``).
**Structure**
- Place related bullets together; dont mix unrelated concepts in the same section.
- Order sections from general → specific → supporting info.
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
- Match structure to complexity:
- Multi-part or detailed results → use clear headers and grouped bullets.
- Simple results → minimal headers, possibly just a short list or paragraph.
**Tone**
- Keep the voice collaborative and natural, like a coding partner handing off work.
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
- Keep descriptions self-contained; dont refer to “above” or “below”.
- Use parallel structure in lists for consistency.
**Dont**
- Dont use literal words “bold” or “monospace” in the content.
- Dont nest bullets or create deep hierarchies.
- Dont output ANSI escape codes directly — the CLI renderer applies them.
- Dont cram unrelated keywords into a single bullet; split for clarity.
- Dont let keyword lists run long — wrap or reformat for scanability.
Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with whats needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
# Tools
## `apply_patch`
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
**_ Begin Patch
[ one or more file sections ]
*** End Patch
_** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
**_ Add File: <path> - create a new file. Every following line is a + line (the initial contents).
_** Delete File: <path> - remove an existing file. Nothing follows.
\*\*\* Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by \*\*\* Move to: <new path> if you want to rename the file.
@@ -77,60 +281,46 @@ Within a hunk each line starts with:
At the end of a truncated hunk you can emit \*\*\* End of File.
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
Begin := "**_ Begin Patch" NEWLINE
End := "_** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
AddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "_** Delete File: " path NEWLINE
UpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "_** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
**_ Begin Patch
_** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
**_ Update File: src/app.py
_** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
**_ Delete File: obsolete.txt
_** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- You must follow this schema exactly when providing a patch
You can invoke apply_patch with the following shell command:
You can invoke apply_patch like:
```
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
```
## Sandbox environment and approval instructions
## `update_plan`
You are running in a sandboxed workspace backed by version control. The sandbox might be configured by the user to restrict certain behaviors, like accessing the internet or writing to files outside the current directory.
A tool named `update_plan` is available to you. You can use it to keep an uptodate, stepbystep plan for the task.
Commands that are blocked by sandbox settings will be automatically sent to the user for approval. The result of the request will be returned (i.e. the command result, or the request denial).
The user also has an opportunity to approve the same command for the rest of the session.
To create a new plan, call `update_plan` with a short list of 1sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).
Guidance on running within the sandbox:
- When running commands that will likely require approval, attempt to use simple, precise commands, to reduce frequency of approval requests.
- When approval is denied or a command fails due to a permission error, do not retry the exact command in a different way. Move on and continue trying to address the user's request.
## Tools available
### Plan updates
A tool named `update_plan` is available. Use it to keep an uptodate, stepbystep plan for the task so you can follow your progress. When making your plans, keep in mind that you are a deployed coding agent - `update_plan` calls should not involve doing anything that you aren't capable of doing. For example, `update_plan` calls should NEVER contain tasks to merge your own pull requests. Only stop to ask the user if you genuinely need their feedback on a change.
- At the start of any nontrivial task, call `update_plan` with an initial plan: a short list of 1sentence steps with a `status` for each step (`pending`, `in_progress`, or `completed`). There should always be exactly one `in_progress` step until everything is done.
- Whenever you finish a step, call `update_plan` again, marking the finished step as `completed` and the next step as `in_progress`.
- If your plan needs to change, call `update_plan` with the revised steps and include an `explanation` describing the change.
- When all steps are complete, make a final `update_plan` call with all steps marked `completed`.
When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.
If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.

View File

@@ -31,6 +31,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::UsageLimitReachedError;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
@@ -40,6 +41,16 @@ use crate::protocol::TokenUsage;
use crate::util::backoff;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: Error,
}
#[derive(Debug, Deserialize)]
struct Error {
r#type: String,
}
#[derive(Clone)]
pub struct ModelClient {
config: Arc<Config>,
@@ -127,15 +138,6 @@ impl ModelClient {
let auth_mode = auth.as_ref().map(|a| a.mode);
if self.config.model_family.family == "2025-08-06-model"
&& auth_mode != Some(AuthMode::ChatGPT)
{
return Err(CodexErr::UnexpectedStatus(
StatusCode::BAD_REQUEST,
"2025-08-06-model is only supported with ChatGPT auth, run `codex login status` to check your auth status and `codex login` to login with ChatGPT".to_string(),
));
}
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
@@ -194,7 +196,7 @@ impl ModelClient {
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id().await
&& let Some(account_id) = auth.get_account_id()
{
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
@@ -234,6 +236,14 @@ impl ModelClient {
}
Ok(res) => {
let status = res.status();
// Pull out RetryAfter header if present.
let retry_after_secs = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
// errors. When we bubble early with only the HTTP status the caller sees an opaque
// "unexpected status 400 Bad Request" which makes debugging nearly impossible.
@@ -247,16 +257,29 @@ impl ModelClient {
return Err(CodexErr::UnexpectedStatus(status, body));
}
if attempt > max_retries {
return Err(CodexErr::RetryLimit(status));
if status == StatusCode::TOO_MANY_REQUESTS {
let body = res.json::<ErrorResponse>().await.ok();
if let Some(ErrorResponse {
error: Error { r#type, .. },
}) = body
{
if r#type == "usage_limit_reached" {
return Err(CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type: auth.and_then(|a| a.get_plan_type()),
}));
} else if r#type == "usage_not_included" {
return Err(CodexErr::UsageNotIncluded);
}
}
}
// Pull out RetryAfter header if present.
let retry_after_secs = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
if attempt > max_retries {
if status == StatusCode::INTERNAL_SERVER_ERROR {
return Err(CodexErr::InternalServerError);
}
return Err(CodexErr::RetryLimit(status));
}
let delay = retry_after_secs
.map(|s| Duration::from_millis(s * 1_000))

View File

@@ -46,6 +46,7 @@ use crate::conversation_history::ConversationHistory;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::error::SandboxErr;
use crate::error::get_error_message_ui;
use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
@@ -468,6 +469,57 @@ impl Session {
}
}
}
/// Runs the exec tool call and emits events for the begin and end of the
/// command even on error.
///
/// Returns the output of the exec tool call.
async fn run_exec_with_events<'a>(
&self,
turn_diff_tracker: &mut TurnDiffTracker,
begin_ctx: ExecCommandContext,
exec_args: ExecInvokeArgs<'a>,
) -> crate::error::Result<ExecToolCallOutput> {
let is_apply_patch = begin_ctx.apply_patch.is_some();
let sub_id = begin_ctx.sub_id.clone();
let call_id = begin_ctx.call_id.clone();
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
.await;
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
exec_args.ctrl_c,
exec_args.sandbox_policy,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
)
.await;
let output_stderr;
let borrowed: &ExecToolCallOutput = match &result {
Ok(output) => output,
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
stdout: String::new(),
stderr: get_error_message_ui(e),
duration: Duration::default(),
};
&output_stderr
}
};
self.on_exec_command_end(
turn_diff_tracker,
&sub_id,
&call_id,
borrowed,
is_apply_patch,
)
.await;
result
}
/// Helper that emits a BackgroundEvent with the given message. This keeps
/// the callsites terse so adding more diagnostics does not clutter the
@@ -636,7 +688,7 @@ impl AgentTask {
let event = Event {
id: self.sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "Turn interrupted".to_string(),
message: " Turn interrupted".to_string(),
}),
};
let tx_event = self.sess.tx_event.clone();
@@ -1238,6 +1290,9 @@ async fn run_turn(
Ok(output) => return Ok(output),
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
Err(e @ (CodexErr::UsageLimitReached(_) | CodexErr::UsageNotIncluded)) => {
return Err(e);
}
Err(e) => {
// Use the configured provider-specific stream retry budget.
let max_retries = sess.client.get_provider().stream_max_retries();
@@ -1717,6 +1772,15 @@ fn parse_container_exec_arguments(
}
}
pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
pub ctrl_c: Arc<Notify>,
pub sandbox_policy: &'a SandboxPolicy,
pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
pub stdout_stream: Option<StdoutStream>,
}
fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
if sess.shell_environment_policy.use_profile {
let command = sess
@@ -1887,23 +1951,26 @@ async fn handle_container_exec_with_params(
},
),
};
sess.on_exec_command_begin(turn_diff_tracker, exec_command_context.clone())
.await;
let params = maybe_run_with_user_profile(params, sess);
let output_result = process_exec_tool_call(
params.clone(),
sandbox_type,
sess.ctrl_c.clone(),
&sess.sandbox_policy,
&sess.codex_linux_sandbox_exe,
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
)
.await;
let output_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context.clone(),
ExecInvokeArgs {
params: params.clone(),
sandbox_type,
ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
},
)
.await;
match output_result {
Ok(output) => {
@@ -1914,24 +1981,14 @@ async fn handle_container_exec_with_params(
duration,
} = &output;
sess.on_exec_command_end(
turn_diff_tracker,
&sub_id,
&call_id,
&output,
exec_command_context.apply_patch.is_some(),
)
.await;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
);
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content,
success: Some(is_success),
@@ -1949,16 +2006,13 @@ async fn handle_container_exec_with_params(
)
.await
}
Err(e) => {
// Handle non-sandbox errors
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("execution error: {e}"),
success: None,
},
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("execution error: {e}"),
success: None,
},
},
}
}
@@ -1973,7 +2027,6 @@ async fn handle_sandbox_error(
let call_id = exec_command_context.call_id.clone();
let sub_id = exec_command_context.sub_id.clone();
let cwd = exec_command_context.cwd.clone();
let is_apply_patch = exec_command_context.apply_patch.is_some();
// Early out if either the user never wants to be asked for approval, or
// we're letting the model manage escalation requests. Otherwise, continue
@@ -2039,24 +2092,26 @@ async fn handle_sandbox_error(
sess.notify_background_event(&sub_id, "retrying command without sandbox")
.await;
sess.on_exec_command_begin(turn_diff_tracker, exec_command_context)
.await;
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
let retry_output_result = process_exec_tool_call(
params,
SandboxType::None,
sess.ctrl_c.clone(),
&sess.sandbox_policy,
&sess.codex_linux_sandbox_exe,
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
)
.await;
let retry_output_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context.clone(),
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
},
)
.await;
match retry_output_result {
Ok(retry_output) => {
@@ -2067,15 +2122,6 @@ async fn handle_sandbox_error(
duration,
} = &retry_output;
sess.on_exec_command_end(
turn_diff_tracker,
&sub_id,
&call_id,
&retry_output,
is_apply_patch,
)
.await;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
@@ -2084,23 +2130,20 @@ async fn handle_sandbox_error(
);
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content,
success: Some(is_success),
},
}
}
Err(e) => {
// Handle retry failure
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("retry failed: {e}"),
success: None,
},
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("retry failed: {e}"),
success: None,
},
},
}
}
ReviewDecision::Denied | ReviewDecision::Abort => {

View File

@@ -6,7 +6,7 @@ use crate::config::Config;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::util::notify_on_sigint;
use codex_login::load_auth;
use codex_login::CodexAuth;
use tokio::sync::Notify;
use uuid::Uuid;
@@ -26,7 +26,7 @@ pub struct CodexConversation {
/// that callers can surface the information to the UI.
pub async fn init_codex(config: Config) -> anyhow::Result<CodexConversation> {
let ctrl_c = notify_on_sigint();
let auth = load_auth(&config.codex_home, true)?;
let auth = CodexAuth::from_codex_home(&config.codex_home)?;
let CodexSpawnOk {
codex,
init_id,

View File

@@ -4,12 +4,11 @@ use crate::config_types::McpServerConfig;
use crate::config_types::ReasoningEffort;
use crate::config_types::ReasoningSummary;
use crate::config_types::SandboxMode;
use crate::config_types::SandboxWorkplaceWrite;
use crate::config_types::SandboxWorkspaceWrite;
use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::flags::OPENAI_DEFAULT_MODEL;
use crate::model_family::ModelFamily;
use crate::model_family::find_family_for_model;
use crate::model_provider_info::ModelProviderInfo;
@@ -22,13 +21,19 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
const OPENAI_DEFAULT_MODEL: &str = "gpt-5";
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
const CONFIG_TOML_FILE: &str = "config.toml";
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
@@ -191,10 +196,28 @@ impl Config {
}
}
pub fn load_config_as_toml_with_cli_overrides(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
let mut root_value = load_config_as_toml(codex_home)?;
for (path, value) in cli_overrides.into_iter() {
apply_toml_override(&mut root_value, &path, value);
}
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
Ok(cfg)
}
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
/// an empty TOML table when the file does not exist.
fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
let config_path = codex_home.join("config.toml");
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
match std::fs::read_to_string(&config_path) {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(val) => Ok(val),
@@ -214,6 +237,35 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
}
}
/// Patch `CODEX_HOME/config.toml` project state.
/// Use with caution.
pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Parse existing config if present; otherwise start a new document.
let mut doc = match std::fs::read_to_string(config_path.clone()) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Mark the project as trusted. toml_edit is very good at handling
// missing properties
let project_key = project_path.to_string_lossy().to_string();
doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted");
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
// create a tmp_file
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
// atomically move the tmp file into config.toml
tmp_file.persist(config_path)?;
Ok(())
}
/// Apply a single dotted-path override onto a TOML value.
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
use toml::value::Table;
@@ -282,7 +334,7 @@ pub struct ConfigToml {
pub sandbox_mode: Option<SandboxMode>,
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkplaceWrite>,
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
@@ -350,6 +402,13 @@ pub struct ConfigToml {
/// The value for the `originator` header included with Responses API requests.
pub internal_originator: Option<String>,
pub projects: Option<HashMap<String, ProjectConfig>>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProjectConfig {
pub trust_level: Option<String>,
}
impl ConfigToml {
@@ -361,16 +420,52 @@ impl ConfigToml {
match resolved_sandbox_mode {
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
Some(s) => SandboxPolicy::WorkspaceWrite {
writable_roots: s.writable_roots.clone(),
network_access: s.network_access,
include_default_writable_roots: true,
Some(SandboxWorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
None => SandboxPolicy::new_workspace_write_policy(),
},
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
}
}
pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool {
let projects = self.projects.clone().unwrap_or_default();
projects
.get(&resolved_cwd.to_string_lossy().to_string())
.map(|p| p.trust_level.clone().unwrap_or("".to_string()) == "trusted")
.unwrap_or(false)
}
pub fn get_config_profile(
&self,
override_profile: Option<String>,
) -> Result<ConfigProfile, std::io::Error> {
let profile = override_profile.or_else(|| self.profile.clone());
match profile {
Some(key) => {
if let Some(profile) = self.profiles.get(key.as_str()) {
return Ok(profile.clone());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("config profile `{key}` not found"),
))
}
None => Ok(ConfigProfile::default()),
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).
@@ -745,8 +840,10 @@ sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = [
"/tmp",
"/my/workspace",
]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
"#;
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(sandbox_workspace_write)
@@ -754,9 +851,10 @@ writable_roots = [
let sandbox_mode_override = None;
assert_eq!(
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/tmp")],
writable_roots: vec![PathBuf::from("/my/workspace")],
network_access: false,
include_default_writable_roots: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
sandbox_workspace_write_cfg.derive_sandbox_policy(sandbox_mode_override)
);

View File

@@ -93,11 +93,15 @@ pub enum SandboxMode {
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct SandboxWorkplaceWrite {
pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub exclude_tmpdir_env_var: bool,
#[serde(default)]
pub exclude_slash_tmp: bool,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
@@ -105,10 +109,10 @@ pub struct SandboxWorkplaceWrite {
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would
/// include HOME, LOGNAME, PATH, SHELL, and USER, among others.
#[default]
Core,
/// Inherits the full environment from the parent process.
#[default]
All,
/// Do not inherit any environment variables from the parent process.
@@ -167,7 +171,8 @@ pub struct ShellEnvironmentPolicy {
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
fn from(toml: ShellEnvironmentPolicyToml) -> Self {
let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::Core);
// Default to inheriting the full environment when not specified.
let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All);
let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(false);
let exclude = toml
.exclude

View File

@@ -62,6 +62,17 @@ pub enum CodexErr {
#[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
#[error(
"To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing."
)]
UsageNotIncluded,
#[error("We're currently experiencing high demand, which may cause temporary errors.")]
InternalServerError,
/// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),
@@ -104,6 +115,30 @@ pub enum CodexErr {
EnvVar(EnvVarError),
}
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub plan_type: Option<String>,
}
impl std::fmt::Display for UsageLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(plan_type) = &self.plan_type
&& plan_type == "plus"
{
write!(
f,
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
)?;
} else {
write!(
f,
"You've hit your usage limit. Limits reset every 5h and every week."
)?;
}
Ok(())
}
}
#[derive(Debug)]
pub struct EnvVarError {
/// Name of the environment variable that is missing.
@@ -132,3 +167,46 @@ impl CodexErr {
(self as &dyn std::any::Any).downcast_ref::<T>()
}
}
pub fn get_error_message_ui(e: &CodexErr) -> String {
match e {
CodexErr::Sandbox(SandboxErr::Denied(_, _, stderr)) => stderr.to_string(),
_ => e.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_limit_reached_error_formats_plus_plan() {
let err = UsageLimitReachedError {
plan_type: Some("plus".to_string()),
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
);
}
#[test]
fn usage_limit_reached_error_formats_default_when_none() {
let err = UsageLimitReachedError { plan_type: None };
assert_eq!(
err.to_string(),
"You've hit your usage limit. Limits reset every 5h and every week."
);
}
#[test]
fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError {
plan_type: Some("pro".to_string()),
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. Limits reset every 5h and every week."
);
}
}

View File

@@ -3,7 +3,6 @@ use std::time::Duration;
use env_flags::env_flags;
env_flags! {
pub OPENAI_DEFAULT_MODEL: &str = "codex-mini-latest";
pub OPENAI_API_BASE: &str = "https://api.openai.com/v1";
/// Fallback when the provider-specific key is not set.

View File

@@ -89,9 +89,9 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
simple_model_family!(slug, "gpt-oss")
} else if slug.starts_with("gpt-3.5") {
simple_model_family!(slug, "gpt-3.5")
} else if slug.starts_with("2025-08-06-model") {
} else if slug.starts_with("gpt-5") {
model_family!(
slug, "2025-08-06-model",
slug, "gpt-5",
supports_reasoning_summaries: true,
)
} else {

View File

@@ -15,7 +15,7 @@ use std::time::Duration;
use crate::error::EnvVarError;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 10;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
/// Wire protocol that the provider speaks. Most third-party services only
@@ -96,7 +96,7 @@ impl ModelProviderInfo {
auth: &Option<CodexAuth>,
) -> crate::error::Result<reqwest::RequestBuilder> {
let effective_auth = match self.api_key() {
Ok(Some(key)) => Some(CodexAuth::from_api_key(key)),
Ok(Some(key)) => Some(CodexAuth::from_api_key(&key)),
Ok(None) => auth.clone(),
Err(err) => {
if auth.is_some() {

View File

@@ -77,7 +77,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
max_output_tokens: 4_096,
}),
"2025-08-06-model" => Some(ModelInfo {
"gpt-5" => Some(ModelInfo {
context_window: 200_000,
max_output_tokens: 100_000,
}),

View File

@@ -139,7 +139,6 @@ pub enum AskForApproval {
/// Under this policy, only "known safe" commands—as determined by
/// `is_safe_command()`—that **only read files** are autoapproved.
/// Everything else will ask the user to approve.
#[default]
#[serde(rename = "untrusted")]
#[strum(serialize = "untrusted")]
UnlessTrusted,
@@ -151,6 +150,7 @@ pub enum AskForApproval {
OnFailure,
/// The model decides when to ask the user for approval.
#[default]
OnRequest,
/// Never ask the user to approve commands. Failures are immediately returned
@@ -185,11 +185,16 @@ pub enum SandboxPolicy {
#[serde(default)]
network_access: bool,
/// When set to `true`, will include defaults like the current working
/// directory and TMPDIR (on macOS). When `false`, only `writable_roots`
/// are used. (Mainly used for testing.)
#[serde(default = "default_true")]
include_default_writable_roots: bool,
/// When set to `true`, will NOT include the per-user `TMPDIR`
/// environment variable among the default writable roots. Defaults to
/// `false`.
#[serde(default)]
exclude_tmpdir_env_var: bool,
/// When set to `true`, will NOT include the `/tmp` among the default
/// writable roots on UNIX. Defaults to `false`.
#[serde(default)]
exclude_slash_tmp: bool,
},
}
@@ -203,10 +208,6 @@ pub struct WritableRoot {
pub read_only_subpaths: Vec<PathBuf>,
}
fn default_true() -> bool {
true
}
impl FromStr for SandboxPolicy {
type Err = serde_json::Error;
@@ -228,7 +229,8 @@ impl SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
}
@@ -263,27 +265,40 @@ impl SandboxPolicy {
SandboxPolicy::ReadOnly => Vec::new(),
SandboxPolicy::WorkspaceWrite {
writable_roots,
include_default_writable_roots,
..
exclude_tmpdir_env_var,
exclude_slash_tmp,
network_access: _,
} => {
// Start from explicitly configured writable roots.
let mut roots: Vec<PathBuf> = writable_roots.clone();
// Optionally include defaults (cwd and TMPDIR on macOS).
if *include_default_writable_roots {
roots.push(cwd.to_path_buf());
// Always include defaults: cwd, /tmp (if present on Unix), and
// on macOS, the per-user TMPDIR unless explicitly excluded.
roots.push(cwd.to_path_buf());
// Also include the per-user tmp dir on macOS.
// Note this is added dynamically rather than storing it in
// `writable_roots` because `writable_roots` contains only static
// values deserialized from the config file.
if cfg!(target_os = "macos") {
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
roots.push(PathBuf::from(tmpdir));
}
// Include /tmp on Unix unless explicitly excluded.
if cfg!(unix) && !exclude_slash_tmp {
let slash_tmp = PathBuf::from("/tmp");
if slash_tmp.is_dir() {
roots.push(slash_tmp);
}
}
// Include $TMPDIR unless explicitly excluded. On macOS, TMPDIR
// is per-user, so writes to TMPDIR should not be readable by
// other users on the system.
//
// By comparison, TMPDIR is not guaranteed to be defined on
// Linux or Windows, but supporting it here gives users a way to
// provide the model with their own temporary directory without
// having to hardcode it in the config.
if !exclude_tmpdir_env_var
&& let Some(tmpdir) = std::env::var_os("TMPDIR")
&& !tmpdir.is_empty()
{
roots.push(PathBuf::from(tmpdir));
}
// For each root, compute subpaths that should remain read-only.
roots
.into_iter()
@@ -433,6 +448,28 @@ impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
}
pub fn cached_input(&self) -> u64 {
self.cached_input_tokens.unwrap_or(0)
}
pub fn non_cached_input(&self) -> u64 {
self.input_tokens.saturating_sub(self.cached_input())
}
/// Primary count for display as a single absolute value: non-cached input + output.
pub fn blended_total(&self) -> u64 {
self.non_cached_input() + self.output_tokens
}
/// For estimating what % of the model's context window is used, we need to account
/// for reasoning output tokens from prior turns being dropped from the context window.
/// We approximate this here by subtracting reasoning output tokens from the total.
/// This will be off for the current turn and pending function calls.
pub fn tokens_in_context_window(&self) -> u64 {
self.total_tokens
.saturating_sub(self.reasoning_output_tokens.unwrap_or(0))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -448,17 +485,20 @@ impl From<TokenUsage> for FinalOutput {
impl fmt::Display for FinalOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let u = &self.token_usage;
let token_usage = &self.token_usage;
write!(
f,
"Token usage: total={} input={}{} output={}{}",
u.total_tokens,
u.input_tokens,
u.cached_input_tokens
.map(|c| format!(" (cached {c})"))
.unwrap_or_default(),
u.output_tokens,
u.reasoning_output_tokens
token_usage.blended_total(),
token_usage.non_cached_input(),
if token_usage.cached_input() > 0 {
format!(" (+ {} cached)", token_usage.cached_input())
} else {
String::new()
},
token_usage.output_tokens,
token_usage
.reasoning_output_tokens
.map(|r| format!(" (reasoning {r})"))
.unwrap_or_default()
)

View File

@@ -134,6 +134,11 @@ mod tests {
#[test]
fn create_seatbelt_args_with_read_only_git_subpath() {
if cfg!(target_os = "windows") {
// /tmp does not exist on Windows, so skip this test.
return;
}
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
let tmp = TempDir::new().expect("tempdir");
@@ -144,19 +149,21 @@ mod tests {
root_with_git_git_canon,
root_without_git_canon,
} = populate_tmpdir(tmp.path());
let cwd = tmp.path().join("cwd");
// Build a policy that only includes the two test roots as writable and
// does not automatically include defaults like cwd or TMPDIR.
// does not automatically include defaults TMPDIR or /tmp.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![root_with_git.clone(), root_without_git.clone()],
network_access: false,
include_default_writable_roots: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let args = create_seatbelt_command_args(
vec!["/bin/echo".to_string(), "hello".to_string()],
&policy,
tmp.path(),
&cwd,
);
// Build the expected policy text using a raw string for readability.
@@ -169,12 +176,12 @@ mod tests {
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1"))
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
)
"#,
);
let expected_args = vec![
let mut expected_args = vec![
"-p".to_string(),
expected_policy,
format!(
@@ -189,16 +196,25 @@ mod tests {
"-DWRITABLE_ROOT_1={}",
root_without_git_canon.to_string_lossy()
),
format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()),
];
expected_args.extend(vec![
"--".to_string(),
"/bin/echo".to_string(),
"hello".to_string(),
];
]);
assert_eq!(args, expected_args);
assert_eq!(expected_args, args);
}
#[test]
fn create_seatbelt_args_for_cwd_as_git_repo() {
if cfg!(target_os = "windows") {
// /tmp does not exist on Windows, so skip this test.
return;
}
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
let tmp = TempDir::new().expect("tempdir");
@@ -215,7 +231,8 @@ mod tests {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let args = create_seatbelt_command_args(
@@ -224,17 +241,14 @@ mod tests {
root_with_git.as_path(),
);
let tmpdir_env_var = if cfg!(target_os = "macos") {
std::env::var("TMPDIR")
.ok()
.map(PathBuf::from)
.and_then(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().to_string())
} else {
None
};
let tmpdir_env_var = std::env::var("TMPDIR")
.ok()
.map(PathBuf::from)
.and_then(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().to_string());
let tempdir_policy_entry = if tmpdir_env_var.is_some() {
" (subpath (param \"WRITABLE_ROOT_1\"))"
r#" (subpath (param "WRITABLE_ROOT_2"))"#
} else {
""
};
@@ -249,7 +263,7 @@ mod tests {
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ){tempdir_policy_entry}
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
)
"#,
);
@@ -265,10 +279,17 @@ mod tests {
"-DWRITABLE_ROOT_0_RO_0={}",
root_with_git_git_canon.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
PathBuf::from("/tmp")
.canonicalize()
.expect("canonicalize /tmp")
.to_string_lossy()
),
];
if let Some(p) = tmpdir_env_var {
expected_args.push(format!("-DWRITABLE_ROOT_1={p}"));
expected_args.push(format!("-DWRITABLE_ROOT_2={p}"));
}
expected_args.extend(vec![
@@ -277,7 +298,7 @@ mod tests {
"hello".to_string(),
]);
assert_eq!(args, expected_args);
assert_eq!(expected_args, args);
}
struct PopulatedTmp {

View File

@@ -1,3 +1,4 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
@@ -5,10 +6,8 @@ use rand::Rng;
use tokio::sync::Notify;
use tracing::debug;
use crate::config::Config;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 1.3;
const BACKOFF_FACTOR: f64 = 2.0;
/// Make a CancellationToken that is fulfilled when SIGINT occurs.
pub fn notify_on_sigint() -> Arc<Notify> {
@@ -47,8 +46,8 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn is_inside_git_repo(config: &Config) -> bool {
let mut dir = config.cwd.to_path_buf();
pub fn is_inside_git_repo(base_dir: &Path) -> bool {
let mut dir = base_dir.to_path_buf();
loop {
if dir.join(".git").exists() {

View File

@@ -1,8 +1,5 @@
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
use std::path::PathBuf;
#![allow(clippy::expect_used, clippy::unwrap_used)]
use chrono::Utc;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ModelProviderInfo;
@@ -13,10 +10,7 @@ use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_login::AuthDotJson;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_login::TokenData;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
@@ -99,7 +93,7 @@ async fn includes_session_id_and_model_headers_in_request() {
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
@@ -173,7 +167,7 @@ async fn includes_base_instructions_override_in_request() {
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
@@ -232,7 +226,7 @@ async fn originator_config_override_is_used() {
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
@@ -290,13 +284,10 @@ async fn chatgpt_auth_sends_correct_request() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(auth_from_token("Access Token".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
let CodexSpawnOk { codex, .. } =
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
.await
.unwrap();
codex
.submit(Op::UserInput {
@@ -373,7 +364,7 @@ async fn includes_user_instructions_message_in_request() {
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c.clone(),
)
.await
@@ -541,13 +532,10 @@ async fn env_var_overrides_loaded_auth() {
config.model_provider = provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(auth_from_token("Default Access Token".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
let CodexSpawnOk { codex, .. } =
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
.await
.unwrap();
codex
.submit(Op::UserInput {
@@ -561,20 +549,6 @@ async fn env_var_overrides_loaded_auth() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
}
fn auth_from_token(id_token: String) -> CodexAuth {
CodexAuth::new(
None,
AuthMode::ChatGPT,
PathBuf::new(),
Some(AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token,
access_token: "Access Token".to_string(),
refresh_token: "test".to_string(),
account_id: Some("account_id".to_string()),
}),
last_refresh: Some(Utc::now()),
}),
)
fn create_dummy_codex_auth() -> CodexAuth {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
}

View File

@@ -145,7 +145,7 @@ async fn summarize_context_three_requests_and_instructions() {
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("dummy".to_string())),
Some(CodexAuth::from_api_key("dummy")),
ctrl_c.clone(),
)
.await

View File

@@ -76,7 +76,8 @@ async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_parent.clone()],
network_access: false,
include_default_writable_roots: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
test_scenario
@@ -101,7 +102,8 @@ async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_root.clone()],
network_access: false,
include_default_writable_roots: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
test_scenario

View File

@@ -99,7 +99,7 @@ async fn retries_on_early_close() {
config.model_provider = model_provider;
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
Some(CodexAuth::from_api_key("Test API Key")),
ctrl_c,
)
.await

View File

@@ -34,6 +34,7 @@ pub struct Cli {
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
#[arg(
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false,
conflicts_with = "full_auto"
)]

View File

@@ -21,7 +21,6 @@ use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TurnDiffEvent;
use owo_colors::OwoColorize;
use owo_colors::Style;
@@ -183,8 +182,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => {
ts_println!(self, "tokens used: {total_tokens}");
EventMsg::TokenCount(token_usage) => {
ts_println!(self, "tokens used: {}", token_usage.blended_total());
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {

View File

@@ -180,8 +180,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// is using.
event_processor.print_config_summary(&config, &prompt);
if !skip_git_repo_check && !is_inside_git_repo(&config) {
eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
std::process::exit(1);
}

View File

@@ -51,7 +51,11 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.to_vec(),
network_access: false,
include_default_writable_roots: true,
// Exclude tmp-related folders from writable roots because we need a
// folder that is writable by tests but that we intentionally disallow
// writing to in the sandbox.
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));

View File

@@ -7,10 +7,12 @@ version = { workspace = true }
workspace = true
[dependencies]
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2.0.12"
tokio = { version = "1", features = [
"io-std",
"macros",
@@ -20,4 +22,5 @@ tokio = { version = "1", features = [
] }
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"

View File

@@ -6,6 +6,7 @@ use serde::Serialize;
use std::env;
use std::fs::File;
use std::fs::OpenOptions;
use std::fs::remove_file;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
@@ -19,6 +20,11 @@ use std::sync::Mutex;
use std::time::Duration;
use tokio::process::Command;
pub use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
mod token_data;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -32,8 +38,9 @@ pub enum AuthMode {
#[derive(Debug, Clone)]
pub struct CodexAuth {
pub api_key: Option<String>,
pub mode: AuthMode,
api_key: Option<String>,
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
auth_file: PathBuf,
}
@@ -45,33 +52,23 @@ impl PartialEq for CodexAuth {
}
impl CodexAuth {
pub fn new(
api_key: Option<String>,
mode: AuthMode,
auth_file: PathBuf,
auth_dot_json: Option<AuthDotJson>,
) -> Self {
let auth_dot_json = Arc::new(Mutex::new(auth_dot_json));
pub fn from_api_key(api_key: &str) -> Self {
Self {
api_key,
mode,
auth_file,
auth_dot_json,
}
}
pub fn from_api_key(api_key: String) -> Self {
Self {
api_key: Some(api_key),
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
auth_file: PathBuf::new(),
auth_dot_json: Arc::new(Mutex::new(None)),
}
}
/// Loads the available auth information from the auth.json or
/// OPENAI_API_KEY environment variable.
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, true)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
#[expect(clippy::unwrap_used)]
let auth_dot_json = self.auth_dot_json.lock().unwrap().clone();
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
match auth_dot_json {
Some(AuthDotJson {
tokens: Some(mut tokens),
@@ -126,65 +123,135 @@ impl CodexAuth {
}
}
pub async fn get_account_id(&self) -> Option<String> {
match self.mode {
AuthMode::ApiKey => None,
AuthMode::ChatGPT => {
let token_data = self.get_token_data().await.ok()?;
pub fn get_account_id(&self) -> Option<String> {
self.get_current_token_data()
.and_then(|t| t.account_id.clone())
}
token_data.account_id.clone()
}
pub fn get_plan_type(&self) -> Option<String> {
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
}
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
#[expect(clippy::unwrap_used)]
self.auth_dot_json.lock().unwrap().clone()
}
fn get_current_token_data(&self) -> Option<TokenData> {
self.get_current_auth_json().and_then(|t| t.tokens.clone())
}
/// Consider this private to integration tests.
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
let auth_dot_json = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: Default::default(),
access_token: "Access Token".to_string(),
refresh_token: "test".to_string(),
account_id: Some("account_id".to_string()),
}),
last_refresh: Some(Utc::now()),
};
let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
Self {
api_key: None,
mode: AuthMode::ChatGPT,
auth_file: PathBuf::new(),
auth_dot_json,
}
}
}
// Loads the available auth information from the auth.json or OPENAI_API_KEY environment variable.
pub fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result<Option<CodexAuth>> {
fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result<Option<CodexAuth>> {
// First, check to see if there is a valid auth.json file. If not, we fall
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
// (if it is set).
let auth_file = get_auth_file(codex_home);
let auth_dot_json = try_read_auth_json(&auth_file).ok();
let auth_json_api_key = auth_dot_json
.as_ref()
.and_then(|a| a.openai_api_key.clone())
.filter(|s| !s.is_empty());
let openai_api_key = if include_env_var {
env::var(OPENAI_API_KEY_ENV_VAR)
.ok()
.filter(|s| !s.is_empty())
.or(auth_json_api_key)
} else {
auth_json_api_key
let auth_dot_json = match try_read_auth_json(&auth_file) {
Ok(auth) => auth,
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
// environment variable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
return match read_openai_api_key_from_env() {
Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
None => Ok(None),
};
}
// Though if auth.json exists but is malformed, do not fall back to the
// env var because the user may be expecting to use AuthMode::ChatGPT.
Err(e) => {
return Err(e);
}
};
let has_tokens = auth_dot_json
.as_ref()
.and_then(|a| a.tokens.as_ref())
.is_some();
let AuthDotJson {
openai_api_key: auth_json_api_key,
tokens,
last_refresh,
} = auth_dot_json;
if openai_api_key.is_none() && !has_tokens {
return Ok(None);
// If the auth.json has an API key AND does not appear to be on a plan that
// should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
if let Some(api_key) = &auth_json_api_key {
// Should any of these be AuthMode::ChatGPT with the api_key set?
// Does AuthMode::ChatGPT indicate that there is an auth.json that is
// "refreshable" even if we are using the API key for auth?
match &tokens {
Some(tokens) => {
if tokens.is_plan_that_should_use_api_key() {
return Ok(Some(CodexAuth::from_api_key(api_key)));
} else {
// Ignore the API key and fall through to ChatGPT auth.
}
}
None => {
// We have an API key but no tokens in the auth.json file.
// Perhaps the user ran `codex login --api-key <KEY>` or updated
// auth.json by hand. Either way, let's assume they are trying
// to use their API key.
return Ok(Some(CodexAuth::from_api_key(api_key)));
}
}
}
let mode = if openai_api_key.is_some() {
AuthMode::ApiKey
} else {
AuthMode::ChatGPT
};
// For the AuthMode::ChatGPT variant, perhaps neither api_key nor
// openai_api_key should exist?
Ok(Some(CodexAuth {
api_key: openai_api_key,
mode,
api_key: None,
mode: AuthMode::ChatGPT,
auth_file,
auth_dot_json: Arc::new(Mutex::new(auth_dot_json)),
auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
openai_api_key: None,
tokens,
last_refresh,
}))),
}))
}
fn get_auth_file(codex_home: &Path) -> PathBuf {
fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
.ok()
.filter(|s| !s.is_empty())
}
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
/// if a file was removed, `Ok(false)` if no auth file was present.
pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
let auth_file = get_auth_file(codex_home);
match remove_file(&auth_file) {
Ok(_) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
/// Represents a running login subprocess. The child can be killed by holding
/// the mutex and calling `kill()`.
#[derive(Debug, Clone)]
@@ -320,7 +387,7 @@ async fn update_tokens(
let mut auth_dot_json = try_read_auth_json(auth_file)?;
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
tokens.id_token = id_token.to_string();
tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?;
if let Some(access_token) = access_token {
tokens.access_token = access_token.to_string();
}
@@ -391,26 +458,21 @@ pub struct AuthDotJson {
pub last_refresh: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
pub struct TokenData {
/// This is a JWT.
pub id_token: String,
/// This is a JWT.
pub access_token: String,
pub refresh_token: String,
pub account_id: Option<String>,
}
#[cfg(test)]
mod tests {
#![expect(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use crate::token_data::IdTokenInfo;
use crate::token_data::KnownPlan;
use crate::token_data::PlanType;
use base64::Engine;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
#[test]
#[expect(clippy::unwrap_used)]
fn writes_api_key_and_loads_auth() {
let dir = tempdir().unwrap();
login_with_api_key(dir.path(), "sk-test-key").unwrap();
@@ -420,7 +482,6 @@ mod tests {
}
#[test]
#[expect(clippy::unwrap_used)]
fn loads_from_env_var_if_env_var_exists() {
let dir = tempdir().unwrap();
@@ -434,45 +495,189 @@ mod tests {
}
#[tokio::test]
#[expect(clippy::unwrap_used)]
async fn loads_token_data_from_auth_json() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
std::fs::write(
auth_file,
format!(
r#"
{{
"OPENAI_API_KEY": null,
"tokens": {{
"id_token": "test-id-token",
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
},
codex_home.path(),
)
.expect("failed to write auth file");
let CodexAuth {
api_key,
mode,
auth_dot_json,
auth_file: _,
} = load_auth(codex_home.path(), false).unwrap().unwrap();
assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode);
let guard = auth_dot_json.lock().unwrap();
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
assert_eq!(
&AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: IdTokenInfo {
email: Some("user@example.com".to_string()),
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
},
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: None,
}),
last_refresh: Some(
DateTime::parse_from_rfc3339(LAST_REFRESH)
.unwrap()
.with_timezone(&Utc)
),
},
auth_dot_json
)
}
/// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
/// [`TokenData::is_plan_that_should_use_api_key`], it should use
/// [`AuthMode::ChatGPT`].
#[tokio::test]
async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: Some("sk-test-key".to_string()),
chatgpt_plan_type: "pro".to_string(),
},
codex_home.path(),
)
.expect("failed to write auth file");
let CodexAuth {
api_key,
mode,
auth_dot_json,
auth_file: _,
} = load_auth(codex_home.path(), false).unwrap().unwrap();
assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode);
let guard = auth_dot_json.lock().unwrap();
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
assert_eq!(
&AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: IdTokenInfo {
email: Some("user@example.com".to_string()),
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
},
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: None,
}),
last_refresh: Some(
DateTime::parse_from_rfc3339(LAST_REFRESH)
.unwrap()
.with_timezone(&Utc)
),
},
auth_dot_json
)
}
/// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
/// account, then it should use [`AuthMode::ApiKey`].
#[tokio::test]
async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: Some("sk-test-key".to_string()),
chatgpt_plan_type: "enterprise".to_string(),
},
codex_home.path(),
)
.expect("failed to write auth file");
let CodexAuth {
api_key,
mode,
auth_dot_json,
auth_file: _,
} = load_auth(codex_home.path(), false).unwrap().unwrap();
assert_eq!(Some("sk-test-key".to_string()), api_key);
assert_eq!(AuthMode::ApiKey, mode);
let guard = auth_dot_json.lock().expect("should unwrap");
assert!(guard.is_none(), "auth_dot_json should be None");
}
struct AuthFileParams {
openai_api_key: Option<String>,
chatgpt_plan_type: String,
}
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
let auth_file = get_auth_file(codex_home);
// Create a minimal valid JWT for the id_token field.
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = serde_json::json!({
"email": "user@example.com",
"email_verified": true,
"https://api.openai.com/auth": {
"chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
"chatgpt_plan_type": params.chatgpt_plan_type,
"chatgpt_user_id": "user-12345",
"user_id": "user-12345",
}
});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header_b64 = b64(&serde_json::to_vec(&header)?);
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
let signature_b64 = b64(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let auth_json_data = json!({
"OPENAI_API_KEY": params.openai_api_key,
"tokens": {
"id_token": fake_jwt,
"access_token": "test-access-token",
"refresh_token": "test-refresh-token"
}},
"last_refresh": "{}"
}}
"#,
Utc::now().to_rfc3339()
),
)
.unwrap();
},
"last_refresh": LAST_REFRESH,
});
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
std::fs::write(auth_file, auth_json)
}
let auth = load_auth(dir.path(), false).unwrap().unwrap();
assert_eq!(auth.mode, AuthMode::ChatGPT);
assert_eq!(auth.api_key, None);
assert_eq!(
auth.get_token_data().await.unwrap(),
TokenData {
id_token: "test-id-token".to_string(),
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: None,
}
);
#[test]
fn id_token_info_handles_missing_fields() {
// Payload without email or plan should yield None values.
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
let payload = serde_json::json!({"sub": "123"});
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&header).unwrap());
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let info = parse_id_token(&jwt).expect("should parse");
assert!(info.email.is_none());
assert!(info.chatgpt_plan_type.is_none());
}
#[tokio::test]
#[expect(clippy::unwrap_used)]
async fn loads_api_key_from_auth_json() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
@@ -494,4 +699,15 @@ mod tests {
assert!(auth.get_token_data().await.is_err());
}
#[test]
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
let dir = tempdir()?;
login_with_api_key(dir.path(), "sk-test-key")?;
assert!(dir.path().join("auth.json").exists());
let removed = logout(dir.path())?;
assert!(removed);
assert!(!dir.path().join("auth.json").exists());
Ok(())
}
}

View File

@@ -0,0 +1,181 @@
use base64::Engine;
use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
pub struct TokenData {
/// Flat info parsed from the JWT in auth.json.
#[serde(deserialize_with = "deserialize_id_token")]
pub id_token: IdTokenInfo,
/// This is a JWT.
pub access_token: String,
pub refresh_token: String,
pub account_id: Option<String>,
}
impl TokenData {
/// Returns true if this is a plan that should use the traditional
/// "metered" billing via an API key.
pub(crate) fn is_plan_that_should_use_api_key(&self) -> bool {
self.id_token
.chatgpt_plan_type
.as_ref()
.is_none_or(|plan| plan.is_plan_that_should_use_api_key())
}
}
/// Flat subset of useful claims in id_token from auth.json.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
pub struct IdTokenInfo {
pub email: Option<String>,
/// The ChatGPT subscription plan type
/// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
/// (Note: ae has not verified that those are the exact values.)
pub(crate) chatgpt_plan_type: Option<PlanType>,
}
impl IdTokenInfo {
pub fn get_chatgpt_plan_type(&self) -> Option<String> {
self.chatgpt_plan_type.as_ref().map(|t| match t {
PlanType::Known(plan) => format!("{plan:?}"),
PlanType::Unknown(s) => s.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum PlanType {
Known(KnownPlan),
Unknown(String),
}
impl PlanType {
fn is_plan_that_should_use_api_key(&self) -> bool {
match self {
Self::Known(known) => {
use KnownPlan::*;
!matches!(known, Free | Plus | Pro | Team)
}
Self::Unknown(_) => {
// Unknown plans should use the API key.
true
}
}
}
pub fn as_string(&self) -> String {
match self {
Self::Known(known) => format!("{known:?}").to_lowercase(),
Self::Unknown(s) => s.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum KnownPlan {
Free,
Plus,
Pro,
Team,
Business,
Enterprise,
Edu,
}
#[derive(Deserialize)]
struct IdClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<PlanType>,
}
#[derive(Debug, Error)]
pub enum IdTokenInfoError {
#[error("invalid ID token format")]
InvalidFormat,
#[error(transparent)]
Base64(#[from] base64::DecodeError),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
// JWT format: header.payload.signature
let mut parts = id_token.split('.');
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return Err(IdTokenInfoError::InvalidFormat),
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
Ok(IdTokenInfo {
email: claims.email,
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
})
}
fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_id_token(&s).map_err(serde::de::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[test]
#[expect(clippy::expect_used, clippy::unwrap_used)]
fn id_token_info_parses_email_and_plan() {
// Build a fake JWT with a URL-safe base64 payload containing email and plan.
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = serde_json::json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "pro"
}
});
fn b64url_no_pad(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
let signature_b64 = b64url_no_pad(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let info = parse_id_token(&fake_jwt).expect("should parse");
assert_eq!(info.email.as_deref(), Some("user@example.com"));
assert_eq!(
info.chatgpt_plan_type,
Some(PlanType::Known(KnownPlan::Pro))
);
}
}

View File

@@ -29,6 +29,7 @@ codex-common = { path = "../common", features = [
"cli",
"elapsed",
"sandbox_summary",
"updates",
] }
codex-core = { path = "../core" }
codex-file-search = { path = "../file-search" }
@@ -36,6 +37,7 @@ codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
lazy_static = "1"
mcp-types = { path = "../mcp-types" }
@@ -47,7 +49,6 @@ ratatui = { version = "0.29.0", features = [
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
@@ -72,10 +73,9 @@ unicode-width = "0.1"
uuid = "1"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
vt100 = "0.16.2"

View File

@@ -3,11 +3,9 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::onboarding::onboarding_screen::KeyEventResult;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::should_show_login_screen;
use crate::slash_command::SlashCommand;
use crate::tui;
@@ -48,10 +46,6 @@ enum AppState<'a> {
/// `AppState`.
widget: Box<ChatWidget<'a>>,
},
/// The start-up warning that recommends running codex inside a Git repo.
GitWarning {
screen: GitWarningScreen,
},
}
pub(crate) struct App<'a> {
@@ -69,18 +63,14 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec<Line<'static>>,
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
/// after dismissing the Git-repo warning.
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
/// deferred until after the Git warning screen is dismissed.
#[derive(Clone)]
struct ChatWidgetArgs {
config: Config,
#[derive(Clone, Debug)]
pub(crate) struct ChatWidgetArgs {
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
@@ -90,8 +80,8 @@ impl App<'_> {
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
@@ -143,30 +133,23 @@ impl App<'_> {
}
let show_login_screen = should_show_login_screen(&config);
let (app_state, chat_args) = if show_login_screen {
(
AppState::Onboarding {
screen: OnboardingScreen::new(app_event_tx.clone(), config.codex_home.clone()),
},
Some(ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
let app_state = if show_login_screen || show_trust_screen {
let chat_widget_args = ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
event_tx: app_event_tx.clone(),
codex_home: config.codex_home.clone(),
cwd: config.cwd.clone(),
show_login_screen,
show_trust_screen,
chat_widget_args,
}),
)
} else if show_git_warning {
(
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
Some(ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
}),
)
}
} else {
let chat_widget = ChatWidget::new(
config.clone(),
@@ -175,12 +158,9 @@ impl App<'_> {
initial_images,
enhanced_keys_supported,
);
(
AppState::Chat {
widget: Box::new(chat_widget),
},
None,
)
AppState::Chat {
widget: Box::new(chat_widget),
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
@@ -192,7 +172,6 @@ impl App<'_> {
config,
file_search,
pending_redraw,
chat_args,
enhanced_keys_supported,
}
}
@@ -249,20 +228,14 @@ impl App<'_> {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::GitWarning { .. } => {
// Allow exiting the app with Ctrl+C from the warning screen.
self.app_event_tx.send(AppEvent::ExitRequest);
}
} => match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
},
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -293,9 +266,6 @@ impl App<'_> {
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
}
KeyEvent {
@@ -321,15 +291,14 @@ impl App<'_> {
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
// User accepted switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
@@ -356,6 +325,12 @@ impl App<'_> {
SlashCommand::Quit => {
break;
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
break;
}
SlashCommand::Diff => {
let (is_git_repo, diff_text) = match get_git_diff() {
Ok(v) => v,
@@ -382,6 +357,11 @@ impl App<'_> {
widget.add_status_output();
}
}
SlashCommand::Prompts => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_prompts_output();
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use std::collections::HashMap;
@@ -424,12 +404,29 @@ impl App<'_> {
},
AppEvent::OnboardingAuthComplete(result) => {
if let AppState::Onboarding { screen } = &mut self.app_state {
// Let the onboarding screen handle success/failure and emit follow-up events.
let _ = screen.on_auth_complete(result);
screen.on_auth_complete(result);
}
}
AppEvent::OnboardingComplete(ChatWidgetArgs {
config,
enhanced_keys_supported,
initial_images,
initial_prompt,
}) => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
)),
}
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
if !query.is_empty() {
self.file_search.on_user_query(query);
}
}
AppEvent::FileSearchResult { query, matches } => {
if let AppState::Chat { widget } = &mut self.app_state {
@@ -447,7 +444,6 @@ impl App<'_> {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
}
}
@@ -476,7 +472,6 @@ impl App<'_> {
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::Onboarding { .. } => size.height,
AppState::GitWarning { .. } => size.height,
};
let mut area = terminal.viewport_area;
@@ -507,7 +502,6 @@ impl App<'_> {
frame.render_widget_ref(&**widget, frame.area())
}
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
})?;
Ok(())
}
@@ -519,49 +513,11 @@ impl App<'_> {
AppState::Chat { widget } => {
widget.handle_key_event(key_event);
}
AppState::Onboarding { screen } => match screen.handle_key_event(key_event) {
KeyEventResult::Continue => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
)),
};
}
KeyEventResult::Quit => {
AppState::Onboarding { screen } => match key_event.code {
KeyCode::Char('q') => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
KeyEventResult::None => {
// do nothing
}
},
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
GitWarningOutcome::Continue => {
// User accepted switch to chat view.
let args = match self.chat_args.take() {
Some(args) => args,
None => panic!("ChatWidgetArgs already consumed"),
};
let widget = Box::new(ChatWidget::new(
args.config,
self.app_event_tx.clone(),
args.initial_prompt,
args.initial_images,
args.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
GitWarningOutcome::Quit => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
GitWarningOutcome::None => {
// do nothing
}
_ => screen.handle_key_event(key_event),
},
}
}
@@ -570,7 +526,6 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
}
}
@@ -578,7 +533,6 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_codex_event(event),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
}
}
}

View File

@@ -3,6 +3,7 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::text::Line;
use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
@@ -51,4 +52,5 @@ pub(crate) enum AppEvent {
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
}

View File

@@ -4,7 +4,7 @@ use crate::app_event::AppEvent;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
app_event_tx: Sender<AppEvent>,
pub app_event_tx: Sender<AppEvent>,
}
impl AppEventSender {

View File

@@ -8,6 +8,7 @@ use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
@@ -30,7 +31,7 @@ use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
use std::cell::RefCell;
const BASE_PLACEHOLDER_TEXT: &str = "...";
const BASE_PLACEHOLDER_TEXT: &str = "Ask Codex to do anything";
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -42,7 +43,8 @@ pub enum InputResult {
}
struct TokenUsageInfo {
token_usage: TokenUsage,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
}
@@ -126,11 +128,13 @@ impl ChatComposer {
/// context when the composer is empty.
pub(crate) fn set_token_usage(
&mut self,
token_usage: TokenUsage,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.token_usage_info = Some(TokenUsageInfo {
token_usage,
total_token_usage,
last_token_usage,
model_context_window,
});
}
@@ -331,8 +335,9 @@ impl ChatComposer {
/// - The cursor may be anywhere *inside* the token (including on the
/// leading `@`). It does **not** need to be at the end of the line.
/// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the token under the cursor starts with `@` and contains at least
/// one additional character, that token (without `@`) is returned.
/// - If the token under the cursor starts with `@`, that token is
/// returned without the leading `@`. This includes the case where the
/// token is just "@" (empty query), which is used to trigger a UI hint
fn current_at_token(textarea: &TextArea) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
@@ -403,14 +408,20 @@ impl ChatComposer {
};
let left_at = token_left
.filter(|t| t.starts_with('@') && t.len() > 1)
.filter(|t| t.starts_with('@'))
.map(|t| t[1..].to_string());
let right_at = token_right
.filter(|t| t.starts_with('@') && t.len() > 1)
.filter(|t| t.starts_with('@'))
.map(|t| t[1..].to_string());
if at_whitespace {
return right_at.or(left_at);
if right_at.is_some() {
return right_at;
}
if token_left.is_some_and(|t| t == "@") {
return None;
}
return left_at;
}
if after_cursor.starts_with('@') {
return right_at.or(left_at);
@@ -453,6 +464,8 @@ impl ChatComposer {
new_text.push_str(&text[end_idx..]);
self.textarea.set_text(&new_text);
let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
/// Handle key event when no popup is visible.
@@ -605,16 +618,26 @@ impl ChatComposer {
return;
}
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
if !query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
match &mut self.active_popup {
ActivePopup::File(popup) => {
popup.set_query(&query);
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
popup.set_query(&query);
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.active_popup = ActivePopup::File(popup);
}
}
@@ -647,7 +670,7 @@ impl WidgetRef for &ChatComposer {
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let key_hint_style = Style::default().fg(Color::Cyan);
let hint = if self.ctrl_c_quit_hint {
let mut hint = if self.ctrl_c_quit_hint {
vec![
Span::from(" "),
"Ctrl+C again".set_style(key_hint_style),
@@ -669,6 +692,33 @@ impl WidgetRef for &ChatComposer {
Span::from(" quit"),
]
};
// Append token/context usage info to the footer hints when available.
if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.total_token_usage;
hint.push(Span::from(" "));
hint.push(
Span::from(format!("{} tokens used", token_usage.total_tokens))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- (last_token_usage.total_tokens as f32 / context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
100
};
hint.push(Span::from(" "));
hint.push(
Span::from(format!("{percent_remaining}% context left"))
.style(Style::default().add_modifier(Modifier::DIM)),
);
}
}
Line::from(hint)
.style(Style::default().dim())
.render_ref(bottom_line_rect, buf);
@@ -690,37 +740,11 @@ impl WidgetRef for &ChatComposer {
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
let placeholder = if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.token_usage;
let model_context_window = token_usage_info.model_context_window;
match (token_usage.total_tokens, model_context_window) {
(total_tokens, Some(context_window)) => {
let percent_remaining: u8 = if context_window > 0 {
// Calculate the percentage of context left.
let percent =
100.0 - (total_tokens as f32 / context_window as f32 * 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
// If we don't have a context window, we cannot compute the
// percentage.
100
};
// When https://github.com/openai/codex/issues/1257 is resolved,
// check if `percent_remaining < 25`, and if so, recommend
// /compact.
format!("{BASE_PLACEHOLDER_TEXT}{percent_remaining}% context left")
}
(total_tokens, None) => {
format!("{BASE_PLACEHOLDER_TEXT}{total_tokens} tokens used")
}
}
} else {
BASE_PLACEHOLDER_TEXT.to_string()
};
Line::from(placeholder)
Line::from(BASE_PLACEHOLDER_TEXT)
.style(Style::default().dim())
.render_ref(textarea_rect.inner(Margin::new(1, 0)), buf);
}
@@ -771,7 +795,12 @@ mod tests {
("@👍", 2, Some("👍".to_string()), "Emoji token"),
// Invalid cases (should return None)
("hello", 2, None, "No @ symbol"),
("@", 1, None, "Only @ symbol"),
(
"@",
1,
Some("".to_string()),
"Only @ symbol triggers empty query",
),
("@ hello", 2, None, "@ followed by space"),
("test @ world", 6, None, "@ with spaces around"),
];
@@ -805,7 +834,7 @@ mod tests {
"Second token",
),
// Edge cases
("@", 0, None, "Only @ symbol"),
("@", 0, Some("".to_string()), "Only @ symbol"),
("@a", 2, Some("a".to_string()), "Single character after @"),
("", 0, None, "Empty input"),
];

View File

@@ -1,30 +1,19 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::symbols::border::QUADRANT_LEFT_HALF;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
const FIRST_COLUMN_WIDTH: u16 = 20;
use ratatui::style::Modifier;
use codex_common::fuzzy_match::fuzzy_match;
pub(crate) struct CommandPopup {
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
selected_idx: Option<usize>,
state: ScrollState,
}
impl CommandPopup {
@@ -32,7 +21,7 @@ impl CommandPopup {
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
selected_idx: None,
state: ScrollState::new(),
}
}
@@ -62,130 +51,84 @@ impl CommandPopup {
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
self.selected_idx = match matches_len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
};
self.state.clamp_selection(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
/// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
/// Return the list of commands that match the current filter. Matching is
/// performed using a *prefix* comparison on the command name.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
self.all_commands
.iter()
.filter_map(|(_name, cmd)| {
if self.command_filter.is_empty()
|| cmd
.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
{
Some(cmd)
} else {
None
/// Compute fuzzy-filtered matches paired with optional highlight indices and score.
/// Sorted by ascending score, then by command name for stability.
fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
let filter = self.command_filter.trim();
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
if filter.is_empty() {
for (_, cmd) in self.all_commands.iter() {
out.push((cmd, None, 0));
}
} else {
for (_, cmd) in self.all_commands.iter() {
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
out.push((cmd, Some(indices), score));
}
})
.collect::<Vec<&SlashCommand>>()
}
}
out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
out
}
fn filtered_commands(&self) -> Vec<&SlashCommand> {
self.filtered().into_iter().map(|(c, _, _)| c).collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
if len == usize::MAX {
return;
}
}
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.filtered_commands().is_empty() {
self.selected_idx = Some(0);
}
let matches = self.filtered_commands();
let len = matches.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_commands().len();
if matches_len == 0 {
self.selected_idx = None;
return;
}
match self.selected_idx {
Some(idx) if idx + 1 < matches_len => {
self.selected_idx = Some(idx + 1);
}
None => {
self.selected_idx = Some(0);
}
_ => {}
}
let matches = self.filtered_commands();
let matches_len = matches.len();
self.state.move_down_wrap(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let matches = self.filtered_commands();
let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> =
matches.into_iter().take(MAX_POPUP_ROWS).collect();
if visible_matches.is_empty() {
rows.push(Row::new(vec![
Cell::from(""),
Cell::from("No matching commands").add_modifier(Modifier::ITALIC),
]));
let matches = self.filtered();
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
Vec::new()
} else {
let default_style = Style::default();
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
rows.push(Row::new(vec![
Cell::from(Line::from(vec![
if Some(idx) == self.selected_idx {
Span::styled(
"",
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
)
} else {
Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
},
Span::styled(format!("/{}", cmd.command()), command_style),
])),
Cell::from(cmd.description().to_string()).style(default_style),
]));
}
}
use ratatui::layout::Constraint;
let table = Table::new(
rows,
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
)
.column_spacing(0);
// .block(
// Block::default()
// .borders(Borders::LEFT)
// .border_type(BorderType::QuadrantOutside)
// .border_style(Style::default().fg(Color::DarkGray)),
// );
table.render(area, buf);
matches
.into_iter()
.map(|(cmd, indices, _)| GenericDisplayRow {
name: format!("/{}", cmd.command()),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some(cmd.description().to_string()),
})
.collect()
};
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}

View File

@@ -1,23 +1,12 @@
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
/// Maximum number of suggestions shown in the popup.
const MAX_RESULTS: usize = 8;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
@@ -30,8 +19,8 @@ pub(crate) struct FileSearchPopup {
waiting: bool,
/// Cached matches; paths relative to the search dir.
matches: Vec<FileMatch>,
/// Currently selected index inside `matches` (if any).
selected_idx: Option<usize>,
/// Shared selection/scroll state.
state: ScrollState,
}
impl FileSearchPopup {
@@ -41,7 +30,7 @@ impl FileSearchPopup {
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
selected_idx: None,
state: ScrollState::new(),
}
}
@@ -61,10 +50,21 @@ impl FileSearchPopup {
if !keep_existing {
self.matches.clear();
self.selected_idx = None;
self.state.reset();
}
}
/// Put the popup into an "idle" state used for an empty query (just "@").
/// Shows a hint instead of matches until the user types more characters.
pub(crate) fn set_empty_prompt(&mut self) {
self.display_query.clear();
self.pending_query.clear();
self.waiting = false;
self.matches.clear();
// Reset selection/scroll state when showing the empty prompt.
self.state.reset();
}
/// Replace matches when a `FileSearchResult` arrives.
/// Replace matches. Only applied when `query` matches `pending_query`.
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
@@ -75,40 +75,32 @@ impl FileSearchPopup {
self.display_query = query.to_string();
self.matches = matches;
self.waiting = false;
self.selected_idx = if self.matches.is_empty() {
None
} else {
Some(0)
};
let len = self.matches.len();
self.state.clamp_selection(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor up.
pub(crate) fn move_up(&mut self) {
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
}
let len = self.matches.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor down.
pub(crate) fn move_down(&mut self) {
if let Some(idx) = self.selected_idx {
if idx + 1 < self.matches.len() {
self.selected_idx = Some(idx + 1);
}
} else if !self.matches.is_empty() {
self.selected_idx = Some(0);
}
let len = self.matches.len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub(crate) fn selected_match(&self) -> Option<&str> {
self.selected_idx
self.state
.selected_idx
.and_then(|idx| self.matches.get(idx))
.map(|file_match| file_match.path.as_str())
}
/// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
@@ -116,71 +108,35 @@ impl FileSearchPopup {
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
self.matches.len().clamp(1, MAX_RESULTS) as u16
self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
}
}
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Prepare rows.
let rows: Vec<Row> = if self.matches.is_empty() {
vec![Row::new(vec![
Cell::from(if self.waiting {
"(searching …)"
} else {
"no matches"
})
.style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
])]
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
Vec::new()
} else {
self.matches
.iter()
.take(MAX_RESULTS)
.enumerate()
.map(|(i, file_match)| {
let FileMatch { path, indices, .. } = file_match;
let path = path.as_str();
#[allow(clippy::expect_used)]
let indices = indices.as_ref().expect("indices should be present");
// Build spans with bold on matching indices.
let mut idx_iter = indices.iter().peekable();
let mut spans: Vec<Span> = Vec::with_capacity(path.len());
for (char_idx, ch) in path.chars().enumerate() {
let mut style = Style::default();
if idx_iter
.peek()
.is_some_and(|next| **next == char_idx as u32)
{
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
// Create cell from the spans.
let mut cell = Cell::from(Line::from(spans));
// If selected, also paint yellow.
if Some(i) == self.selected_idx {
cell = cell.style(Style::default().fg(Color::Yellow));
}
Row::new(vec![cell])
.map(|m| GenericDisplayRow {
name: m.path.clone(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
})
.collect()
};
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
if self.waiting && rows_all.is_empty() {
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
} else {
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}
}

View File

@@ -19,6 +19,9 @@ mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod live_ring_widget;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod status_indicator_view;
mod textarea;
@@ -287,11 +290,12 @@ impl BottomPane<'_> {
/// is forwarded directly to the underlying `ChatComposer`.
pub(crate) fn set_token_usage(
&mut self,
token_usage: TokenUsage,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.composer
.set_token_usage(token_usage, model_context_window);
.set_token_usage(total_token_usage, last_token_usage, model_context_window);
self.request_redraw();
}

View File

@@ -0,0 +1,5 @@
//! Shared popup-related constants for bottom pane widgets.
/// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;

View File

@@ -0,0 +1,115 @@
/// Generic scroll/selection state for a vertical list menu.
///
/// Encapsulates the common behavior of a selectable list that supports:
/// - Optional selection (None when list is empty)
/// - Wrap-around navigation on Up/Down
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
pub scroll_top: usize,
}
impl ScrollState {
pub fn new() -> Self {
Self {
selected_idx: None,
scroll_top: 0,
}
}
/// Reset selection and scroll.
pub fn reset(&mut self) {
self.selected_idx = None;
self.scroll_top = 0;
}
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
};
if len == 0 {
self.scroll_top = 0;
}
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx > 0 => idx - 1,
Some(_) => len - 1,
None => 0,
});
}
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx + 1 < len => idx + 1,
_ => 0,
});
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
if len == 0 || visible_rows == 0 {
self.scroll_top = 0;
return;
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::ScrollState;
#[test]
fn wrap_navigation_and_visibility() {
let mut s = ScrollState::new();
let len = 10;
let vis = 5;
s.clamp_selection(len);
assert_eq!(s.selected_idx, Some(0));
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
}

View File

@@ -0,0 +1,126 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use super::scroll_state::ScrollState;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
}
impl GenericDisplayRow {}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
) {
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(Span::styled(
"no matches",
Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
)))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// Compute starting index based on scroll state and selection.
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
let GenericDisplayRow {
name,
match_indices,
is_current,
description,
} = row;
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
let mut style = Style::default();
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
spans.push(Span::raw(name.clone()));
}
if let Some(desc) = description.as_ref() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
desc.clone(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == state.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
} else if *is_current {
cell = cell.style(Style::default().fg(Color::Cyan));
}
rows.push(Row::new(vec![cell]));
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
}

View File

@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ ... "
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "

View File

@@ -302,6 +302,11 @@ impl TextArea {
code: KeyCode::Left,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
@@ -309,6 +314,11 @@ impl TextArea {
code: KeyCode::Right,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.end_of_next_word());
}

View File

@@ -45,7 +45,6 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
@@ -67,7 +66,8 @@ pub(crate) struct ChatWidget<'a> {
active_history_cell: Option<HistoryCell>,
config: Config,
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
reasoning_buffer: String,
content_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
@@ -214,7 +214,8 @@ impl ChatWidget<'_> {
initial_prompt.unwrap_or_default(),
initial_images,
),
token_usage: TokenUsage::default(),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
content_buffer: String::new(),
answer_buffer: String::new(),
@@ -366,9 +367,13 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::TokenCount(token_usage) => {
self.token_usage = add_token_usage(&self.token_usage, &token_usage);
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
self.last_token_usage = token_usage;
self.bottom_pane.set_token_usage(
self.total_token_usage.clone(),
self.last_token_usage.clone(),
self.config.model_context_window,
);
}
EventMsg::Error(ErrorEvent { message }) => {
self.add_to_history(HistoryCell::new_error_event(message.clone()));
@@ -393,17 +398,6 @@ impl ChatWidget<'_> {
reason,
}) => {
self.finalize_active_stream();
// Log a background summary immediately so the history is chronological.
let cmdline = strip_bash_lc_and_escape(&command);
let text = format!(
"command requires approval:\n$ {cmdline}{reason}",
reason = reason
.as_ref()
.map(|r| format!("\n{r}"))
.unwrap_or_default()
);
self.add_to_history(HistoryCell::new_background_event(text));
let request = ApprovalRequest::Exec {
id,
command,
@@ -476,16 +470,14 @@ impl ChatWidget<'_> {
));
}
EventMsg::PatchApplyEnd(event) => {
self.add_to_history(HistoryCell::new_patch_apply_end(
event.stdout,
event.stderr,
event.success,
));
if !event.success {
self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
}
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
exit_code,
duration,
duration: _,
stdout,
stderr,
}) => {
@@ -498,7 +490,6 @@ impl ChatWidget<'_> {
exit_code,
stdout,
stderr,
duration,
},
));
}
@@ -567,10 +558,14 @@ impl ChatWidget<'_> {
pub(crate) fn add_status_output(&mut self) {
self.add_to_history(HistoryCell::new_status_output(
&self.config,
&self.token_usage,
&self.total_token_usage,
));
}
pub(crate) fn add_prompts_output(&mut self) {
self.add_to_history(HistoryCell::new_prompts_output());
}
/// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
@@ -622,13 +617,16 @@ impl ChatWidget<'_> {
}
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.token_usage
&self.total_token_usage
}
pub(crate) fn clear_token_usage(&mut self) {
self.token_usage = TokenUsage::default();
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
self.total_token_usage = TokenUsage::default();
self.bottom_pane.set_token_usage(
self.total_token_usage.clone(),
self.last_token_usage.clone(),
self.config.model_context_window,
);
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {

View File

@@ -44,6 +44,7 @@ pub struct Cli {
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
#[arg(
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false,
conflicts_with_all = ["approval_policy", "full_auto"]
)]
@@ -53,10 +54,6 @@ pub struct Cli {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}

View File

@@ -1,122 +0,0 @@
//! Fullscreen warning displayed when Codex is started outside a Git
//! repository (unless the user passed `--allow-no-git-exec`). The screen
//! blocks all input until the user explicitly decides whether to continue or
//! quit.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
const NO_GIT_ERROR: &str = "We recommend running codex inside a git repository. \
This helps ensure that changes can be tracked and easily rolled back if necessary. \
Do you wish to proceed?";
/// Result of handling a key event while the warning screen is active.
pub(crate) enum GitWarningOutcome {
/// User chose to proceed switch to the main Chat UI.
Continue,
/// User opted to quit the application.
Quit,
/// No actionable key was pressed stay on the warning screen.
None,
}
pub(crate) struct GitWarningScreen;
impl GitWarningScreen {
pub(crate) fn new() -> Self {
Self
}
/// Handle a key event, returning an outcome indicating whether the user
/// chose to continue, quit, or neither.
pub(crate) fn handle_key_event(&self, key_event: KeyEvent) -> GitWarningOutcome {
match key_event.code {
KeyCode::Char('y') | KeyCode::Char('Y') => GitWarningOutcome::Continue,
KeyCode::Char('n') | KeyCode::Char('q') | KeyCode::Esc => GitWarningOutcome::Quit,
_ => GitWarningOutcome::None,
}
}
}
impl WidgetRef for &GitWarningScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
const MIN_WIDTH: u16 = 35;
const MIN_HEIGHT: u16 = 15;
// Check if the available area is too small for our popup.
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
// Fallback rendering: a simple abbreviated message that fits the available area.
let fallback_message = Paragraph::new(NO_GIT_ERROR)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center);
fallback_message.render(area, buf);
return;
}
// Determine the popup (modal) size aim for 60% width, 30% height
// but keep a sensible minimum so the content is always readable.
let popup_width = std::cmp::max(MIN_WIDTH, (area.width as f32 * 0.6) as u16);
let popup_height = std::cmp::max(MIN_HEIGHT, (area.height as f32 * 0.3) as u16);
// Center the popup in the available area.
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
// The modal block that contains everything.
let popup_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.title(Span::styled(
"Warning: Not a Git repository", // bold warning title
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
));
// Obtain the inner area before rendering (render consumes the block).
let inner = popup_block.inner(popup_area);
popup_block.render(popup_area, buf);
// Split the inner area vertically into two boxes: one for the warning
// explanation, one for the user action instructions.
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(inner);
// ----- First box: detailed warning text --------------------------------
let text_block = Block::default().borders(Borders::ALL);
let text_inner = text_block.inner(chunks[0]);
text_block.render(chunks[0], buf);
let warning_paragraph = Paragraph::new(NO_GIT_ERROR)
.wrap(Wrap { trim: true })
.alignment(Alignment::Left);
warning_paragraph.render(text_inner, buf);
// ----- Second box: "proceed? y/n" instructions --------------------------
let action_block = Block::default().borders(Borders::ALL);
let action_inner = action_block.inner(chunks[1]);
action_block.render(chunks[1], buf);
let action_text = Paragraph::new("press 'y' to continue, 'n' to quit")
.alignment(Alignment::Center)
.style(Style::default().add_modifier(Modifier::BOLD));
action_text.render(action_inner, buf);
}
}

View File

@@ -13,8 +13,11 @@ use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use codex_login::get_auth_file;
use codex_login::try_read_auth_json;
use image::DynamicImage;
use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
@@ -38,7 +41,12 @@ pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) duration: Duration,
}
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
pub(crate) enum PatchEventType {
@@ -103,6 +111,9 @@ pub(crate) enum HistoryCell {
/// Output from the `/status` command.
StatusOutput { view: TextBlock },
/// Output from the `/prompts` command.
PromptsOutput { view: TextBlock },
/// Error event from the backend.
ErrorEvent { view: TextBlock },
@@ -122,7 +133,28 @@ pub(crate) enum HistoryCell {
PatchApplyResult { view: TextBlock },
}
const TOOL_CALL_MAX_LINES: usize = 5;
const TOOL_CALL_MAX_LINES: usize = 3;
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
fn pretty_provider_name(id: &str) -> String {
if id.eq_ignore_ascii_case("openai") {
"OpenAI".to_string()
} else {
title_case(id)
}
}
impl HistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
@@ -135,6 +167,7 @@ impl HistoryCell {
| HistoryCell::BackgroundEvent { view }
| HistoryCell::GitDiffOutput { view }
| HistoryCell::StatusOutput { view }
| HistoryCell::PromptsOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
@@ -189,12 +222,12 @@ impl HistoryCell {
Span::raw(format!(" {cwd_str}")).dim(),
]),
Line::from("".dim()),
Line::from(" Try one of the following commands to get started:".dim()),
Line::from(" To get started, describe a task or try one of these commands:".dim()),
Line::from("".dim()),
Line::from(format!(" 1. /init - {}", SlashCommand::Init.description()).dim()),
Line::from(format!(" 2. /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" 3. /compact - {}", SlashCommand::Compact.description()).dim()),
Line::from(format!(" 4. /new - {}", SlashCommand::New.description()).dim()),
Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()),
Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()),
Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()),
Line::from("".dim()),
];
HistoryCell::WelcomeMessage {
@@ -232,8 +265,11 @@ impl HistoryCell {
let command_escaped = strip_bash_lc_and_escape(&command);
let lines: Vec<Line<'static>> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
Line::from(format!("$ {command_escaped}")),
Line::from(vec![
"".cyan(),
"Running command ".magenta(),
command_escaped.into(),
]),
Line::from(""),
];
@@ -247,34 +283,36 @@ impl HistoryCell {
exit_code,
stdout,
stderr,
duration,
} = output;
let mut lines: Vec<Line<'static>> = Vec::new();
// Title depends on whether we have output yet.
let title_line = Line::from(vec![
"command".magenta(),
format!(
" (code: {}, duration: {})",
exit_code,
format_duration(duration)
)
.dim(),
]);
lines.push(title_line);
let command_escaped = strip_bash_lc_and_escape(&command);
lines.push(Line::from(vec![
"⚡ Ran command ".magenta(),
command_escaped.into(),
]));
let src = if exit_code == 0 { stdout } else { stderr };
let cmdline = strip_bash_lc_and_escape(&command);
lines.push(Line::from(format!("$ {cmdline}")));
let mut lines_iter = src.lines();
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(ansi_escape_line(raw).dim());
for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(line);
}
let remaining = lines_iter.count();
if remaining > 0 {
lines.push(Line::from(format!("... {remaining} additional lines")).dim());
let mut more = Line::from(format!("... +{remaining} lines"));
// Continuation/ellipsis is treated as a subsequent line for prefixing
more.spans.insert(0, " ".into());
more.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(more);
}
lines.push(Line::from(""));
@@ -423,7 +461,8 @@ impl HistoryCell {
view: TextBlock::new(lines),
}
}
// allow dead code for now. maybe we'll use it again.
#[allow(dead_code)]
pub(crate) fn new_background_event(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim()));
@@ -454,37 +493,124 @@ impl HistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/status".magenta()));
// Config
for (key, value) in create_config_summary_entries(config) {
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
let config_entries = create_config_summary_entries(config);
let lookup = |k: &str| -> String {
config_entries
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
// 📂 Workspace
lines.push(Line::from(vec!["📂 ".into(), "Workspace".bold()]));
// Path (home-relative, e.g., ~/code/project)
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
lines.push(Line::from(vec![" • Path: ".into(), cwd_str.into()]));
// Approval mode (as-is)
lines.push(Line::from(vec![
" • Approval Mode: ".into(),
lookup("approval").into(),
]));
// Sandbox (simplified name only)
let sandbox_name = match &config.sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
};
lines.push(Line::from(vec![
" • Sandbox: ".into(),
sandbox_name.into(),
]));
lines.push(Line::from(""));
// 👤 Account (only if ChatGPT tokens exist), shown under the first block
let auth_file = get_auth_file(&config.codex_home);
if let Ok(auth) = try_read_auth_json(&auth_file) {
if let Some(tokens) = auth.tokens.clone() {
lines.push(Line::from(vec!["👤 ".into(), "Account".bold()]));
lines.push(Line::from(" • Signed in with ChatGPT"));
let info = tokens.id_token;
if let Some(email) = &info.email {
lines.push(Line::from(vec![" • Login: ".into(), email.clone().into()]));
}
match auth.openai_api_key.as_deref() {
Some(key) if !key.is_empty() => {
lines.push(Line::from(
" • Using API key. Run codex login to use ChatGPT plan",
));
}
_ => {
let plan_text = info
.get_chatgpt_plan_type()
.map(|s| title_case(&s))
.unwrap_or_else(|| "Unknown".to_string());
lines.push(Line::from(vec![" • Plan: ".into(), plan_text.into()]));
}
}
lines.push(Line::from(""));
}
}
// 🧠 Model
lines.push(Line::from(vec!["🧠 ".into(), "Model".bold()]));
lines.push(Line::from(vec![
" • Name: ".into(),
config.model.clone().into(),
]));
let provider_disp = pretty_provider_name(&config.model_provider_id);
lines.push(Line::from(vec![
" • Provider: ".into(),
provider_disp.into(),
]));
// Only show Reasoning fields if present in config summary
let reff = lookup("reasoning effort");
if !reff.is_empty() {
lines.push(Line::from(vec![
" • Reasoning Effort: ".into(),
title_case(&reff).into(),
]));
}
let rsum = lookup("reasoning summaries");
if !rsum.is_empty() {
lines.push(Line::from(vec![
" • Reasoning Summaries: ".into(),
title_case(&rsum).into(),
]));
}
// Token usage
lines.push(Line::from(""));
lines.push(Line::from("token usage".bold()));
// 📊 Token Usage
lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()]));
// Input: <input> [+ <cached> cached]
let mut input_line_spans: Vec<Span<'static>> = vec![
" • Input: ".into(),
usage.non_cached_input().to_string().into(),
];
if let Some(cached) = usage.cached_input_tokens {
if cached > 0 {
input_line_spans.push(format!(" (+ {cached} cached)").into());
}
}
lines.push(Line::from(input_line_spans));
// Output: <output>
lines.push(Line::from(vec![
" input: ".bold(),
usage.input_tokens.to_string().into(),
]));
lines.push(Line::from(vec![
" cached input: ".bold(),
usage.cached_input_tokens.unwrap_or(0).to_string().into(),
]));
lines.push(Line::from(vec![
" output: ".bold(),
" • Output: ".into(),
usage.output_tokens.to_string().into(),
]));
// Total: <total>
lines.push(Line::from(vec![
" reasoning output: ".bold(),
usage
.reasoning_output_tokens
.unwrap_or(0)
.to_string()
.into(),
]));
lines.push(Line::from(vec![
" total: ".bold(),
usage.total_tokens.to_string().into(),
" • Total: ".into(),
usage.blended_total().to_string().into(),
]));
lines.push(Line::from(""));
@@ -493,58 +619,73 @@ impl HistoryCell {
}
}
pub(crate) fn new_error_event(message: String) -> Self {
pub(crate) fn new_prompts_output() -> Self {
let lines: Vec<Line<'static>> = vec![
vec!["ERROR: ".red().bold(), message.into()].into(),
"".into(),
Line::from("/prompts".magenta()),
Line::from(""),
Line::from(" 1. Explain this codebase"),
Line::from(" 2. Summarize recent commits"),
Line::from(" 3. Implement {feature}"),
Line::from(" 4. Find and fix a bug in @filename"),
Line::from(" 5. Write tests for @filename"),
Line::from(" 6. Improve documentation in @filename"),
Line::from(""),
];
HistoryCell::PromptsOutput {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_error_event(message: String) -> Self {
let lines: Vec<Line<'static>> =
vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()];
HistoryCell::ErrorEvent {
view: TextBlock::new(lines),
}
}
/// Render a userfriendly plan update with colourful status icons and a
/// simple progress indicator so users can follow along.
/// Render a userfriendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> Self {
let UpdatePlanArgs { explanation, plan } = update;
let mut lines: Vec<Line<'static>> = Vec::new();
// Header with progress summary
let total = plan.len();
let completed = plan
.iter()
.filter(|p| matches!(p.status, StepStatus::Completed))
.count();
// Title
lines.push(Line::from("plan".magenta().bold()));
let width: usize = 10;
let filled = if total > 0 {
(completed * width + total / 2) / total
} else {
0
};
let empty = width.saturating_sub(filled);
if !plan.is_empty() {
// Progress bar show completed/total with a visual bar
let total = plan.len();
let completed = plan
.iter()
.filter(|p| matches!(p.status, StepStatus::Completed))
.count();
let width: usize = 20;
let filled = (completed * width + total / 2) / total;
let empty = width.saturating_sub(filled);
let mut bar_spans: Vec<Span> = Vec::new();
if filled > 0 {
bar_spans.push(Span::styled(
"".repeat(filled),
Style::default().fg(Color::Green),
));
}
if empty > 0 {
bar_spans.push(Span::styled(
"".repeat(empty),
Style::default().fg(Color::Gray),
));
}
let progress_prefix = Span::raw("progress [");
let progress_suffix = Span::raw("] ");
let fraction = Span::raw(format!("{completed}/{total}"));
let mut progress_line_spans = vec![progress_prefix];
progress_line_spans.extend(bar_spans);
progress_line_spans.push(progress_suffix);
progress_line_spans.push(fraction);
lines.push(Line::from(progress_line_spans));
let mut header: Vec<Span> = Vec::new();
header.push(Span::raw("📋"));
header.push(Span::styled(
" Updated",
Style::default().add_modifier(Modifier::BOLD).magenta(),
));
header.push(Span::raw(" to do list ["));
if filled > 0 {
header.push(Span::styled(
"".repeat(filled),
Style::default().fg(Color::Green),
));
}
if empty > 0 {
header.push(Span::styled(
"".repeat(empty),
Style::default().fg(Color::Gray),
));
}
header.push(Span::raw("] "));
header.push(Span::raw(format!("{completed}/{total}")));
lines.push(Line::from(header));
// Optional explanation/note from the model
if let Some(expl) = explanation.and_then(|s| {
@@ -557,22 +698,48 @@ impl HistoryCell {
}
}
// Steps (1based numbering) with fun, readable status icons
// Steps styled as checkbox items
if plan.is_empty() {
lines.push(Line::from("(no steps provided)".gray().italic()));
} else {
for (idx, PlanItemArg { step, status }) in plan.into_iter().enumerate() {
let num = idx + 1;
let icon_span: Span = match status {
StepStatus::Completed => Span::from("").fg(Color::Green),
StepStatus::InProgress => Span::from("").fg(Color::Yellow).bold(),
StepStatus::Pending => Span::from("").fg(Color::Gray),
let (box_span, text_span) = match status {
StepStatus::Completed => (
Span::styled("", Style::default().fg(Color::Green)),
Span::styled(
step,
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::CROSSED_OUT | Modifier::DIM),
),
),
StepStatus::InProgress => (
Span::raw(""),
Span::styled(
step,
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
),
StepStatus::Pending => (
Span::raw(""),
Span::styled(
step,
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
),
),
};
let prefix = if idx == 0 {
Span::raw("")
} else {
Span::raw(" ")
};
lines.push(Line::from(vec![
format!("{num:>2}. [").into(),
icon_span,
"] ".into(),
step.into(),
prefix,
box_span,
Span::raw(" "),
text_span,
]));
}
}
@@ -595,12 +762,12 @@ impl HistoryCell {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
} => "applying patch",
} => "✏️ Applying patch",
PatchEventType::ApplyBegin {
auto_approved: false,
} => {
let lines: Vec<Line<'static>> = vec![
Line::from("applying patch".magenta().bold()),
Line::from("✏️ Applying patch".magenta().bold()),
Line::from(""),
];
return Self::PendingPatch {
@@ -609,39 +776,12 @@ impl HistoryCell {
}
};
let summary_lines = create_diff_summary(changes);
let summary_lines = create_diff_summary(title, changes);
let mut lines: Vec<Line<'static>> = Vec::new();
// Header similar to the command formatter so patches are visually
// distinct while still fitting the overall colour scheme.
lines.push(Line::from(title.magenta().bold()));
for line in summary_lines {
if line.starts_with('+') {
lines.push(line.green().into());
} else if line.starts_with('-') {
lines.push(line.red().into());
} else if let Some(space_idx) = line.find(' ') {
let kind_owned = line[..space_idx].to_string();
let rest_owned = line[space_idx + 1..].to_string();
let style_for = |fg: Color| Style::default().fg(fg).add_modifier(Modifier::BOLD);
let styled_kind = match kind_owned.as_str() {
"A" => RtSpan::styled(kind_owned.clone(), style_for(Color::Green)),
"D" => RtSpan::styled(kind_owned.clone(), style_for(Color::Red)),
"M" => RtSpan::styled(kind_owned.clone(), style_for(Color::Yellow)),
"R" | "C" => RtSpan::styled(kind_owned.clone(), style_for(Color::Cyan)),
_ => RtSpan::raw(kind_owned.clone()),
};
let styled_line =
RtLine::from(vec![styled_kind, RtSpan::raw(" "), RtSpan::raw(rest_owned)]);
lines.push(styled_line);
} else {
lines.push(Line::from(line));
}
lines.push(line);
}
lines.push(Line::from(""));
@@ -651,44 +791,23 @@ impl HistoryCell {
}
}
pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
pub(crate) fn new_patch_apply_failure(stderr: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
let status = if success {
RtSpan::styled("patch applied", Style::default().fg(Color::Green))
} else {
RtSpan::styled(
"patch failed",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
};
lines.push(RtLine::from(vec![
"patch".magenta().bold(),
" ".into(),
status,
]));
// Failure title
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
let src = if success {
if stdout.trim().is_empty() {
&stderr
} else {
&stdout
}
} else if stderr.trim().is_empty() {
&stdout
} else {
&stderr
};
if !src.trim().is_empty() {
lines.push(Line::from(""));
let mut iter = src.lines();
for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(ansi_escape_line(raw).dim());
if !stderr.trim().is_empty() {
let mut iter = stderr.lines();
for (i, raw) in iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let s = format!("{prefix}{raw}");
lines.push(ansi_escape_line(&s).dim());
}
let remaining = iter.count();
if remaining > 0 {
lines.push(Line::from(format!("... {remaining} additional lines")).dim());
lines.push(Line::from(""));
lines.push(Line::from(format!("... +{remaining} lines")).dim());
}
}
@@ -708,36 +827,138 @@ impl WidgetRef for &HistoryCell {
}
}
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
// Build a concise, humanreadable summary list similar to the
// `git status` short format so the user can reason about the
// patch without scrolling.
let mut summaries: Vec<String> = Vec::new();
fn create_diff_summary(title: &str, changes: HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut files: Vec<FileSummary> = Vec::new();
// Count additions/deletions from a unified diff body
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
let mut adds = 0usize;
let mut dels = 0usize;
for hunk in patch.hunks() {
for line in hunk.lines() {
match line {
diffy::Line::Insert(_) => adds += 1,
diffy::Line::Delete(_) => dels += 1,
_ => {}
}
}
}
(adds, dels)
} else {
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
}
(adds, dels)
}
};
for (path, change) in &changes {
use codex_core::protocol::FileChange::*;
match change {
Add { content } => {
let added = content.lines().count();
summaries.push(format!("A {} (+{added})", path.display()));
files.push(FileSummary {
display_path: path.display().to_string(),
added,
removed: 0,
});
}
Delete => {
summaries.push(format!("D {}", path.display()));
let removed = std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0);
files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed,
});
}
Update {
unified_diff,
move_path,
} => {
if let Some(new_path) = move_path {
summaries.push(format!("R {}{}", path.display(), new_path.display(),));
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{}{}", path.display(), new_path.display())
} else {
summaries.push(format!("M {}", path.display(),));
}
summaries.extend(unified_diff.lines().map(|s| s.to_string()));
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
}
summaries
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
out
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {

View File

@@ -6,10 +6,13 @@ use app::App;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config_types::SandboxMode;
use codex_core::protocol::AskForApproval;
use codex_core::util::is_inside_git_repo;
use codex_login::load_auth;
use codex_core::protocol::SandboxPolicy;
use codex_login::CodexAuth;
use codex_ollama::DEFAULT_OSS_MODEL;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
@@ -31,7 +34,6 @@ pub mod custom_terminal;
mod exec_command;
mod file_search;
mod get_git_diff;
mod git_warning_screen;
mod history_cell;
pub mod insert_history;
pub mod live_wrap;
@@ -46,8 +48,6 @@ mod text_formatting;
mod tui;
mod user_approval_widget;
#[cfg(not(debug_assertions))]
mod updates;
#[cfg(not(debug_assertions))]
use color_eyre::owo_colors::OwoColorize;
@@ -91,33 +91,38 @@ pub async fn run_main(
None
};
let config = {
// canonicalize the cwd
let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
let overrides = ConfigOverrides {
model,
approval_policy,
sandbox_mode,
cwd,
model_provider: model_provider_override,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: Some(true),
disable_response_storage: cli.oss.then_some(true),
show_raw_agent_reasoning: cli.oss.then_some(true),
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
let mut config = {
// Load configuration and support CLI overrides.
let overrides = ConfigOverrides {
model,
approval_policy,
sandbox_mode,
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
model_provider: model_provider_override,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: Some(true),
disable_response_storage: cli.oss.then_some(true),
show_raw_agent_reasoning: cli.oss.then_some(true),
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
@@ -126,6 +131,34 @@ pub async fn run_main(
}
};
// we load config.toml here to determine project state.
#[allow(clippy::print_stderr)]
let config_toml = {
let codex_home = match find_codex_home() {
Ok(codex_home) => codex_home,
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) {
Ok(config_toml) => config_toml,
Err(err) => {
eprintln!("Error loading config.toml: {err}");
std::process::exit(1);
}
}
};
let should_show_trust_screen = determine_repo_trust_state(
&mut config,
&config_toml,
approval_policy,
sandbox_mode,
cli.config_profile.clone(),
)?;
let log_dir = codex_core::config::log_dir(&config)?;
std::fs::create_dir_all(&log_dir)?;
// Open (or create) your log file, appending to it.
@@ -176,7 +209,7 @@ pub async fn run_main(
#[allow(clippy::print_stderr)]
#[cfg(not(debug_assertions))]
if let Some(latest_version) = updates::get_upgrade_version(&config) {
if let Some(latest_version) = codex_common::updates::get_upgrade_version(&config) {
let current_version = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()?;
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
@@ -206,20 +239,14 @@ pub async fn run_main(
eprintln!("");
}
// Determine whether we need to display the "not a git repo" warning
// modal. The flag is shown when the current working directory is *not*
// inside a Git repository **and** the user did *not* pass the
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
run_ratatui_app(cli, config, show_git_warning, log_rx)
run_ratatui_app(cli, config, should_show_trust_screen, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
}
fn run_ratatui_app(
cli: Cli,
config: Config,
show_git_warning: bool,
should_show_trust_screen: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
@@ -237,7 +264,7 @@ fn run_ratatui_app(
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, show_git_warning, images);
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{
@@ -275,7 +302,7 @@ fn should_show_login_screen(config: &Config) -> bool {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
match load_auth(&codex_home, true) {
match CodexAuth::from_codex_home(&codex_home) {
Ok(Some(_)) => false,
Ok(None) => true,
Err(err) => {
@@ -287,3 +314,39 @@ fn should_show_login_screen(config: &Config) -> bool {
false
}
}
/// Determine if user has configured a sandbox / approval policy,
/// or if the current cwd project is trusted, and updates the config
/// accordingly.
fn determine_repo_trust_state(
config: &mut Config,
config_toml: &ConfigToml,
approval_policy_overide: Option<AskForApproval>,
sandbox_mode_override: Option<SandboxMode>,
config_profile_override: Option<String>,
) -> std::io::Result<bool> {
let config_profile = config_toml.get_config_profile(config_profile_override)?;
if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {
// if the user has overridden either approval policy or sandbox mode,
// skip the trust flow
Ok(false)
} else if config_profile.approval_policy.is_some() {
// if the user has specified settings in a config profile, skip the trust flow
// todo: profile sandbox mode?
Ok(false)
} else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() {
// if the user has specified either approval policy or sandbox mode in config.toml
// skip the trust flow
Ok(false)
} else if config_toml.is_cwd_trusted(&config.cwd) {
// if the current cwd project is trusted and no config has been set
// skip the trust flow and set the approval policy and sandbox mode
config.approval_policy = AskForApproval::OnRequest;
config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
Ok(false)
} else {
// if none of the above conditions are met, show the trust screen
Ok(true)
}
}

View File

@@ -18,18 +18,23 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors::LIGHT_BLUE;
use crate::colors::SUCCESS_GREEN;
use crate::onboarding::onboarding_screen::KeyEventResult;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::FrameTicker;
use crate::shimmer::shimmer_spans;
use std::path::PathBuf;
use super::onboarding_screen::StepState;
// no additional imports
#[derive(Debug)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(#[allow(dead_code)] ContinueInBrowserState),
ChatGptSuccessMessage,
ChatGptSuccess,
EnvVarMissing,
EnvVarFound,
}
#[derive(Debug)]
@@ -38,7 +43,6 @@ pub(crate) struct ContinueInBrowserState {
_login_child: Option<codex_login::SpawnedLogin>,
_frame_ticker: Option<FrameTicker>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(child) = &self._login_child {
@@ -52,54 +56,45 @@ impl Drop for ContinueInBrowserState {
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.mode = AuthMode::ChatGPT;
KeyEventResult::None
self.highlighted_mode = AuthMode::ChatGPT;
}
KeyCode::Down | KeyCode::Char('j') => {
self.mode = AuthMode::ApiKey;
KeyEventResult::None
self.highlighted_mode = AuthMode::ApiKey;
}
KeyCode::Char('1') => {
self.mode = AuthMode::ChatGPT;
self.start_chatgpt_login();
KeyEventResult::None
}
KeyCode::Char('2') => {
self.mode = AuthMode::ApiKey;
self.verify_api_key()
}
KeyCode::Enter => match self.mode {
AuthMode::ChatGPT => match &self.sign_in_state {
SignInState::PickMode => self.start_chatgpt_login(),
SignInState::ChatGptContinueInBrowser(_) => KeyEventResult::None,
SignInState::ChatGptSuccess => KeyEventResult::Continue,
KeyCode::Char('2') => self.verify_api_key(),
KeyCode::Enter => match self.sign_in_state {
SignInState::PickMode => match self.highlighted_mode {
AuthMode::ChatGPT => self.start_chatgpt_login(),
AuthMode::ApiKey => self.verify_api_key(),
},
AuthMode::ApiKey => self.verify_api_key(),
SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode,
SignInState::ChatGptSuccessMessage => {
self.sign_in_state = SignInState::ChatGptSuccess
}
_ => {}
},
KeyCode::Esc => {
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
self.sign_in_state = SignInState::PickMode;
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
} else {
KeyEventResult::Quit
}
}
KeyCode::Char('q') => KeyEventResult::Quit,
_ => KeyEventResult::None,
_ => {}
}
}
}
#[derive(Debug)]
pub(crate) struct AuthModeWidget {
pub mode: AuthMode,
pub event_tx: AppEventSender,
pub highlighted_mode: AuthMode,
pub error: Option<String>,
pub sign_in_state: SignInState,
pub event_tx: AppEventSender,
pub codex_home: PathBuf,
}
@@ -109,7 +104,14 @@ impl AuthModeWidget {
Line::from(vec![
Span::raw("> "),
Span::styled(
"Sign in with your ChatGPT account?",
"Sign in with ChatGPT to use Codex as part of your paid plan",
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
"or connect an API key for usage-based billing",
Style::default().add_modifier(Modifier::BOLD),
),
]),
@@ -121,7 +123,7 @@ impl AuthModeWidget {
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.mode == selected_mode;
let is_selected = self.highlighted_mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
let line1 = if is_selected {
@@ -150,18 +152,18 @@ impl AuthModeWidget {
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT or create a new account",
"Leverages your plan, starting at $20 a month for Plus",
"Sign in with ChatGPT",
"Usage included with Plus, Pro, and Team plans",
));
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay only for what you use",
"Pay for what you use",
));
lines.push(Line::from(""));
lines.push(
Line::from("Press Enter to continue")
Line::from(" Press Enter to continue")
.style(Style::default().add_modifier(Modifier::DIM)),
);
if let Some(err) = &self.error {
@@ -184,32 +186,44 @@ impl AuthModeWidget {
let lines = vec![
Line::from(spans),
Line::from(""),
Line::from(" Press Escape to cancel")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(" Press Esc to cancel").style(Style::default().add_modifier(Modifier::DIM)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account")
.style(Style::default().fg(SUCCESS_GREEN)),
Line::from(""),
Line::from("> Before you start:"),
Line::from(""),
Line::from(" Codex can make mistakes"),
Line::from(" Check important info")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(" Decide how much autonomy you want to grant Codex"),
Line::from(vec![
Span::raw(" For more details see the "),
Span::styled(
"\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Due to prompt injection risks, only use it with code you trust"),
Line::from(" For more details see https://github.com/openai/codex")
Line::from(" Codex can make mistakes")
.style(Style::default().fg(Color::White)),
Line::from(" Review the code it writes and commands it runs")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Powered by your ChatGPT account"),
Line::from(" Uses your plan's rate limits and training data preferences")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(vec![
Span::raw(" Uses your plan's rate limits and "),
Span::styled(
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Press Enter to continue").style(Style::default().fg(LIGHT_BLUE)),
];
@@ -219,7 +233,43 @@ impl AuthModeWidget {
.render(area, buf);
}
fn start_chatgpt_login(&mut self) -> KeyEventResult {
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account")
.style(Style::default().fg(SUCCESS_GREEN)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
let lines =
vec![Line::from("✓ Using OPENAI_API_KEY").style(Style::default().fg(SUCCESS_GREEN))];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from(
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment",
)
.style(Style::default().fg(Color::Blue)),
Line::from(""),
Line::from(" Press Enter to return")
.style(Style::default().add_modifier(Modifier::DIM)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn start_chatgpt_login(&mut self) {
self.error = None;
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
Ok(child) => {
@@ -230,27 +280,23 @@ impl AuthModeWidget {
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
});
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
Err(e) => {
self.sign_in_state = SignInState::PickMode;
self.error = Some(e.to_string());
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
}
}
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
fn verify_api_key(&mut self) -> KeyEventResult {
fn verify_api_key(&mut self) {
if std::env::var("OPENAI_API_KEY").is_err() {
self.error =
Some("Set OPENAI_API_KEY in your environment. Learn more: https://platform.openai.com/docs/libraries".to_string());
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
self.sign_in_state = SignInState::EnvVarMissing;
} else {
KeyEventResult::Continue
self.sign_in_state = SignInState::EnvVarFound;
}
self.event_tx.send(AppEvent::RequestRedraw);
}
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
@@ -299,6 +345,18 @@ impl AuthModeWidget {
}
}
impl StepStateProvider for AuthModeWidget {
fn get_step_state(&self) -> StepState {
match &self.sign_in_state {
SignInState::PickMode
| SignInState::EnvVarMissing
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::EnvVarFound => StepState::Complete,
}
}
}
impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match self.sign_in_state {
@@ -308,9 +366,18 @@ impl WidgetRef for AuthModeWidget {
SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf);
}
SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf);
}
SignInState::ChatGptSuccess => {
self.render_chatgpt_success(area, buf);
}
SignInState::EnvVarMissing => {
self.render_env_var_missing(area, buf);
}
SignInState::EnvVarFound => {
self.render_env_var_found(area, buf);
}
}
}
}

View File

@@ -0,0 +1,34 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::app::ChatWidgetArgs;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
use std::sync::Arc;
use std::sync::Mutex;
/// This doesn't render anything explicitly but serves as a signal that we made it to the end and
/// we should continue to the chat.
pub(crate) struct ContinueToChatWidget {
pub event_tx: AppEventSender,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
}
impl StepStateProvider for ContinueToChatWidget {
fn get_step_state(&self) -> StepState {
StepState::Complete
}
}
impl WidgetRef for &ContinueToChatWidget {
fn render_ref(&self, _area: Rect, _buf: &mut Buffer) {
if let Ok(args) = self.chat_widget_args.lock() {
self.event_tx
.send(AppEvent::OnboardingComplete(args.clone()));
}
}
}

View File

@@ -1,3 +1,5 @@
mod auth;
mod continue_to_chat;
pub mod onboarding_screen;
mod trust_directory;
mod welcome;

View File

@@ -1,3 +1,4 @@
use codex_core::util::is_inside_git_repo;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -5,26 +6,39 @@ use ratatui::widgets::WidgetRef;
use codex_login::AuthMode;
use crate::app::ChatWidgetArgs;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInState;
use crate::onboarding::continue_to_chat::ContinueToChatWidget;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
#[allow(clippy::large_enum_variant)]
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
TrustDirectory(TrustDirectoryWidget),
ContinueToChat(ContinueToChatWidget),
}
pub(crate) trait KeyboardHandler {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult;
fn handle_key_event(&mut self, key_event: KeyEvent);
}
pub(crate) enum KeyEventResult {
Continue,
Quit,
None,
pub(crate) enum StepState {
Hidden,
InProgress,
Complete,
}
pub(crate) trait StepStateProvider {
fn get_step_state(&self) -> StepState;
}
pub(crate) struct OnboardingScreen {
@@ -32,50 +46,126 @@ pub(crate) struct OnboardingScreen {
steps: Vec<Step>,
}
pub(crate) struct OnboardingScreenArgs {
pub event_tx: AppEventSender,
pub chat_widget_args: ChatWidgetArgs,
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub show_login_screen: bool,
pub show_trust_screen: bool,
}
impl OnboardingScreen {
pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self {
let steps: Vec<Step> = vec![
Step::Welcome(WelcomeWidget {}),
Step::Auth(AuthModeWidget {
pub(crate) fn new(args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
event_tx,
chat_widget_args,
codex_home,
cwd,
show_login_screen,
show_trust_screen,
} = args;
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !show_login_screen,
})];
if show_login_screen {
steps.push(Step::Auth(AuthModeWidget {
event_tx: event_tx.clone(),
mode: AuthMode::ChatGPT,
highlighted_mode: AuthMode::ChatGPT,
error: None,
sign_in_state: SignInState::PickMode,
codex_home: codex_home.clone(),
}))
}
let is_git_repo = is_inside_git_repo(&cwd);
let highlighted = if is_git_repo {
TrustDirectorySelection::Trust
} else {
// Default to not trusting the directory if it's not a git repo.
TrustDirectorySelection::DontTrust
};
// Share ChatWidgetArgs between steps so changes in the TrustDirectory step
// are reflected when continuing to chat.
let shared_chat_args = Arc::new(Mutex::new(chat_widget_args));
if show_trust_screen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
codex_home,
}),
];
is_git_repo,
selection: None,
highlighted,
error: None,
chat_widget_args: shared_chat_args.clone(),
}))
}
steps.push(Step::ContinueToChat(ContinueToChatWidget {
event_tx: event_tx.clone(),
chat_widget_args: shared_chat_args,
}));
// TODO: add git warning.
Self { event_tx, steps }
}
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) -> KeyEventResult {
if let Some(Step::Auth(state)) = self.steps.last_mut() {
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) {
let current_step = self.current_step_mut();
if let Some(Step::Auth(state)) = current_step {
match result {
Ok(()) => {
state.sign_in_state = SignInState::ChatGptSuccess;
state.sign_in_state = SignInState::ChatGptSuccessMessage;
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
Err(e) => {
state.sign_in_state = SignInState::PickMode;
state.error = Some(e);
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
}
} else {
KeyEventResult::None
}
}
fn current_steps_mut(&mut self) -> Vec<&mut Step> {
let mut out: Vec<&mut Step> = Vec::new();
for step in self.steps.iter_mut() {
match step.get_step_state() {
StepState::Hidden => continue,
StepState::Complete => out.push(step),
StepState::InProgress => {
out.push(step);
break;
}
}
}
out
}
fn current_steps(&self) -> Vec<&Step> {
let mut out: Vec<&Step> = Vec::new();
for step in self.steps.iter() {
match step.get_step_state() {
StepState::Hidden => continue,
StepState::Complete => out.push(step),
StepState::InProgress => {
out.push(step);
break;
}
}
}
out
}
fn current_step_mut(&mut self) -> Option<&mut Step> {
self.steps
.iter_mut()
.find(|step| matches!(step.get_step_state(), StepState::InProgress))
}
}
impl KeyboardHandler for OnboardingScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
if let Some(last_step) = self.steps.last_mut() {
self.event_tx.send(AppEvent::RequestRedraw);
last_step.handle_key_event(key_event)
} else {
KeyEventResult::None
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
active_step.handle_key_event(key_event);
}
self.event_tx.send(AppEvent::RequestRedraw);
}
}
@@ -109,8 +199,10 @@ impl WidgetRef for &OnboardingScreen {
}
let mut i = 0usize;
while i < self.steps.len() && y < bottom {
let step = &self.steps[i];
let current_steps = self.current_steps();
while i < current_steps.len() && y < bottom {
let step = &current_steps[i];
let max_h = bottom.saturating_sub(y);
if max_h == 0 || width == 0 {
break;
@@ -135,10 +227,22 @@ impl WidgetRef for &OnboardingScreen {
}
impl KeyboardHandler for Step {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match self {
Step::Welcome(_) => KeyEventResult::None,
Step::Welcome(_) | Step::ContinueToChat(_) => (),
Step::Auth(widget) => widget.handle_key_event(key_event),
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
}
}
}
impl StepStateProvider for Step {
fn get_step_state(&self) -> StepState {
match self {
Step::Welcome(w) => w.get_step_state(),
Step::Auth(w) => w.get_step_state(),
Step::TrustDirectory(w) => w.get_step_state(),
Step::ContinueToChat(w) => w.get_step_state(),
}
}
}
@@ -152,6 +256,12 @@ impl WidgetRef for Step {
Step::Auth(widget) => {
widget.render_ref(area, buf);
}
Step::TrustDirectory(widget) => {
widget.render_ref(area, buf);
}
Step::ContinueToChat(widget) => {
widget.render_ref(area, buf);
}
}
}
}

View File

@@ -0,0 +1,179 @@
use std::path::PathBuf;
use codex_core::config::set_project_trusted;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::colors::LIGHT_BLUE;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
use crate::app::ChatWidgetArgs;
use std::sync::Arc;
use std::sync::Mutex;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub is_git_repo: bool,
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrustDirectorySelection {
Trust,
DontTrust,
}
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::styled(
"You are running Codex in ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(self.cwd.to_string_lossy().to_string()),
]),
Line::from(""),
];
if self.is_git_repo {
lines.push(Line::from(
" Since this folder is version controlled, you may wish to allow Codex",
));
lines.push(Line::from(
" to work in this folder without asking for approval.",
));
} else {
lines.push(Line::from(
" Since this folder is not version controlled, we recommend requiring",
));
lines.push(Line::from(" approval of all edits and commands."));
}
lines.push(Line::from(""));
let create_option =
|idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
let is_selected = self.highlighted == option;
if is_selected {
Line::from(vec![
Span::styled(
format!("> {}. ", idx + 1),
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
),
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
])
} else {
Line::from(format!(" {}. {}", idx + 1, text))
}
};
if self.is_git_repo {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
"Yes, allow Codex to work in this folder without asking for approval",
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
"No, ask me to approve edits and commands",
));
} else {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
"Allow Codex to work in this folder without asking for approval",
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
"Require approval of edits and commands",
));
}
lines.push(Line::from(""));
if let Some(error) = &self.error {
lines.push(Line::from(format!(" {error}")).fg(Color::Red));
lines.push(Line::from(""));
}
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = TrustDirectorySelection::Trust;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::DontTrust;
}
KeyCode::Char('1') => self.handle_trust(),
KeyCode::Char('2') => self.handle_dont_trust(),
KeyCode::Enter => match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
},
_ => {}
}
}
}
impl StepStateProvider for TrustDirectoryWidget {
fn get_step_state(&self) -> StepState {
match self.selection {
Some(_) => StepState::Complete,
None => StepState::InProgress,
}
}
}
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
if let Err(e) = set_project_trusted(&self.codex_home, &self.cwd) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(e.to_string());
// self.error = Some("Failed to set project trusted".to_string());
}
// Update the in-memory chat config for this session to a more permissive
// policy suitable for a trusted workspace.
if let Ok(mut args) = self.chat_widget_args.lock() {
args.config.approval_policy = AskForApproval::OnRequest;
args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
}
self.selection = Some(TrustDirectorySelection::Trust);
}
fn handle_dont_trust(&mut self) {
self.highlighted = TrustDirectorySelection::DontTrust;
self.selection = Some(TrustDirectorySelection::DontTrust);
}
}

View File

@@ -7,17 +7,32 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
pub(crate) struct WelcomeWidget {}
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
}
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let line = Line::from(vec![
Span::raw("> "),
Span::raw(">_ "),
Span::styled(
"Welcome to Codex, OpenAI's coding agent that runs in your terminal",
"Welcome to Codex, OpenAI's command-line coding agent",
Style::default().add_modifier(Modifier::BOLD),
),
]);
line.render(area, buf);
}
}
impl StepStateProvider for WelcomeWidget {
fn get_step_state(&self) -> StepState {
match self.is_logged_in {
true => StepState::Hidden,
false => StepState::Complete,
}
}
}

View File

@@ -17,6 +17,8 @@ pub enum SlashCommand {
Compact,
Diff,
Status,
Prompts,
Logout,
Quit,
#[cfg(debug_assertions)]
TestApproval,
@@ -26,14 +28,16 @@ impl SlashCommand {
/// User-visible description shown in the popup.
pub fn description(self) -> &'static str {
match self {
SlashCommand::New => "Start a new chat",
SlashCommand::Init => "Create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "Compact the chat history",
SlashCommand::Quit => "Exit the application",
SlashCommand::Diff => "Show git diff (including untracked files)",
SlashCommand::Status => "Show current session configuration and token usage",
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Logout => "log out of Codex",
#[cfg(debug_assertions)]
SlashCommand::TestApproval => "Test approval request",
SlashCommand::TestApproval => "test approval request",
}
}

View File

@@ -7,6 +7,7 @@ use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -42,6 +43,7 @@ pub(crate) struct StatusIndicatorWidget {
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
start_time: Instant,
// Keep one sender alive to prevent the channel from closing while the
// animation thread is still running. The field itself is currently not
// accessed anywhere, therefore the leading underscore silences the
@@ -78,6 +80,7 @@ impl StatusIndicatorWidget {
reveal_len_at_base: 0,
frame_idx,
running,
start_time: Instant::now(),
_app_event_tx: app_event_tx,
}
@@ -167,11 +170,13 @@ impl WidgetRef for StatusIndicatorWidget {
return;
}
// Build animated gradient header for the word "Working".
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let header_text = "Working";
let header_chars: Vec<char> = header_text.chars().collect();
let padding = 4usize; // virtual padding around the word for smoother loop
let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx);
let status_prefix: String = self.text.chars().take(shown_now).collect();
let animated_text = "Working";
let header_chars: Vec<char> = animated_text.chars().collect();
let padding = 4usize; // virtual padding around the animated segment for smoother loop
let period = header_chars.len() + padding * 2;
let pos = idx % period;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
@@ -179,7 +184,7 @@ impl WidgetRef for StatusIndicatorWidget {
.unwrap_or(false);
let band_half_width = 2.0; // width of the bright band in characters
let mut header_spans: Vec<Span<'static>> = Vec::new();
let mut animated_spans: Vec<Span<'static>> = Vec::new();
for (i, ch) in header_chars.iter().enumerate() {
let i_pos = i as isize + padding as isize;
let pos = pos as isize;
@@ -199,28 +204,60 @@ impl WidgetRef for StatusIndicatorWidget {
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
// Bold makes dark gray and gray look the same, so don't use it when true color is not supported.
Style::default().fg(color_for_level(level))
};
header_spans.push(Span::styled(ch.to_string(), style));
animated_spans.push(Span::styled(ch.to_string(), style));
}
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;
// Compose a single status line like: "▌ Working [•] waiting for model"
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("", Style::default().fg(Color::Cyan)));
// Gradient header
spans.extend(header_spans);
// Space after header
// Simple dim spinner to the left of the header.
let spinner_frames = ['·', '•', '●', '•'];
const SPINNER_SLOWDOWN: usize = 2;
let spinner_ch = spinner_frames[(idx / SPINNER_SLOWDOWN) % spinner_frames.len()];
spans.push(Span::styled(
" ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
spinner_ch.to_string(),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::raw(" "));
// Space after header
// Animated header after the left bar
spans.extend(animated_spans);
// Space between header and bracket block
spans.push(Span::raw(" "));
// Non-animated, dim bracket content, with only "Ctrl c" bold
let bracket_prefix = format!("({elapsed}s • ");
spans.push(Span::styled(
bracket_prefix,
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
"Ctrl C",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::DIM | Modifier::BOLD),
));
spans.push(Span::styled(
" to interrupt)",
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
));
// Add a space and then the log text (not animated by the gradient)
if !status_prefix.is_empty() {
spans.push(Span::styled(
" ",
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
status_prefix,
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
));
}
// Truncate spans to fit the width.
let mut acc: Vec<Span<'static>> = Vec::new();
@@ -298,4 +335,23 @@ mod tests {
}
assert!(row.contains("Working"), "expected Working header: {row:?}");
}
#[test]
fn spinner_is_rendered() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string());
std::thread::sleep(std::time::Duration::from_millis(120));
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
let ch = buf[(2, 0)].symbol().chars().next().unwrap_or(' ');
assert!(
matches!(ch, '·' | '•' | '●'),
"expected spinner char at col 2: {ch:?}"
);
}
}

View File

@@ -28,7 +28,6 @@ use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
/// Request coming from the agent that needs user approval.
@@ -36,6 +35,7 @@ pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
#[allow(dead_code)]
cwd: PathBuf,
reason: Option<String>,
},
@@ -115,21 +115,18 @@ impl UserApprovalWidget<'_> {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec {
command,
cwd,
reason,
..
command, reason, ..
} => {
let cmd = strip_bash_lc_and_escape(command);
// Maybe try to relativize to the cwd of this process first?
// Will make cwd_str shorter in the common case.
let cwd_str = match relativize_to_home(cwd) {
Some(rel) => format!("~/{}", rel.display()),
None => cwd.display().to_string(),
};
// Present a single-line summary without cwd: "codex wants to run: <cmd>"
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
let mut contents: Vec<Line> = vec![
Line::from(vec!["codex".bold().magenta(), " wants to run:".into()]),
Line::from(vec![cwd_str.dim(), "$".into(), format!(" {cmd}").into()]),
Line::from(vec![
"? ".fg(Color::Blue),
"Codex wants to run ".bold(),
cmd_span,
]),
Line::from(""),
];
if let Some(reason) = reason {
@@ -243,9 +240,52 @@ impl UserApprovalWidget<'_> {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
lines.push(Line::from("approval decision"));
lines.push(Line::from(format!("$ {cmd}")));
lines.push(Line::from(format!("decision: {decision:?}")));
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
ReviewDecision::Approved => {
lines.push(Line::from(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
cmd_span,
" ".into(),
"this time".bold(),
]));
}
ReviewDecision::ApprovedForSession => {
lines.push(Line::from(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
cmd_span,
" ".into(),
"every time this session".bold(),
]));
}
ReviewDecision::Denied => {
lines.push(Line::from(vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
cmd_span,
]));
}
ReviewDecision::Abort => {
lines.push(Line::from(vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
cmd_span,
]));
}
}
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));