Compare commits

..

32 Commits

Author SHA1 Message Date
pakrym-oai
819a5782b6 Deduplicator fixes (#4635) 2025-10-02 16:01:59 -07:00
Jeremy Rose
c0a84473a4 fix false "task complete" state during agent message (#4627)
fixes an issue where user messages wouldn't be queued and ctrl + c would
quit the app instead of canceling the stream during the final agent
message.
2025-10-02 15:41:25 -07:00
pakrym-oai
591a8ecc16 Bump codex version in actions to latest (#4634) 2025-10-02 15:14:57 -07:00
pakrym-oai
c405d8c06c Rename assistant message to agent message and fix item type field naming (#4610)
Naming cleanup
2025-10-02 15:07:14 -07:00
pakrym-oai
138be0fd73 Use GH cli to fetch current issue (#4630)
Attempting to format the env var caused escaping issues
2025-10-02 14:43:40 -07:00
Jeremy Rose
25a2e15ec5 tui: tweaks to dialog display (#4622)
- prefix command approval reasons with "Reason:"
- show keyboard shortcuts for some ListSelectionItems
- remove "description" lines for approval options, and make the labels
more verbose
- add a spacer line in diff display after the path

and some other minor refactors that go along with the above.

<img width="859" height="508" alt="Screenshot 2025-10-02 at 1 24 50 PM"
src="https://github.com/user-attachments/assets/4fa7ecaf-3d3a-406a-bb4d-23e30ce3e5cf"
/>
2025-10-02 21:41:29 +00:00
pakrym-oai
62cc8a4b8d Add issue deduplicator workflow (#4628)
It's a bit hand-holdy in that it pre-downloads issue list but that keeps
codex running in read-only no-network mode.
2025-10-02 14:36:33 -07:00
pakrym-oai
f895d4cbb3 Minor cleanup of codex exec output (#4585)
<img width="850" height="723" alt="image"
src="https://github.com/user-attachments/assets/2ae067bf-ba6b-47bf-9ffe-d1c3f3aa1870"
/>
<img width="872" height="547" alt="image"
src="https://github.com/user-attachments/assets/9058be24-6513-4423-9dae-2d5fd4cbf162"
/>
2025-10-02 14:17:42 -07:00
Ahmed Ibrahim
ed5d656fa8 Revert "chore: sanbox extraction" (#4626)
Reverts openai/codex#4286
2025-10-02 21:09:21 +00:00
pakrym-oai
c43a561916 Add issue labeler workflow (#4621)
Auto label issues using codex cli
2025-10-02 13:39:45 -07:00
pakrym-oai
b93cc0f431 Add a separate exec doc (#4583)
More/better docs.
2025-10-02 13:33:08 -07:00
pakrym-oai
4c566d484a Separate interactive and non-interactive sessions (#4612)
Do not show exec session in VSCode/TUI selector.
2025-10-02 13:06:21 -07:00
easong-openai
06e34d4607 Make model switcher two-stage (#4178)
https://github.com/user-attachments/assets/16d5c67c-e580-4a29-983c-a315f95424ee
2025-10-02 19:38:24 +00:00
Jeremy Rose
45936f8fbd show "Viewed Image" when the model views an image (#4475)
<img width="1022" height="339" alt="Screenshot 2025-09-29 at 4 22 00 PM"
src="https://github.com/user-attachments/assets/12da7358-19be-4010-a71b-496ede6dfbbf"
/>
2025-10-02 18:36:03 +00:00
Jeremy Rose
ec98445abf normalize key hints (#4586)
render key hints the same everywhere.



| Before | After |
|--------|-------|
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 15 42
PM"
src="https://github.com/user-attachments/assets/f88d5db4-04bb-4e89-b571-568222c41e4b"
/> | <img width="672" height="137" alt="Screenshot 2025-10-01 at 5 13 56
PM"
src="https://github.com/user-attachments/assets/1fee6a71-f313-4620-8d9a-10766dc4e195"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 01
PM"
src="https://github.com/user-attachments/assets/5170ab35-88b7-4131-b485-ecebea9f0835"
/> | <img width="816" height="174" alt="Screenshot 2025-10-01 at 5 14 24
PM"
src="https://github.com/user-attachments/assets/6b6bc64c-25b9-4824-b2d7-56f60370870a"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 29
PM"
src="https://github.com/user-attachments/assets/2313b36a-e0a8-4cd2-82be-7d0fe7793c19"
/> | <img width="816" height="134" alt="Screenshot 2025-10-01 at 5 14 37
PM"
src="https://github.com/user-attachments/assets/e18934e8-8e9d-4f46-9809-39c8cb6ee893"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 40
PM"
src="https://github.com/user-attachments/assets/0cc69e4e-8cce-420a-b3e4-be75a7e2c8f5"
/> | <img width="816" height="134" alt="Screenshot 2025-10-01 at 5 14 56
PM"
src="https://github.com/user-attachments/assets/329a5121-ae4a-4829-86e5-4c813543770c"
/> |
2025-10-02 18:34:47 +00:00
dedrisian-oai
b07aafa5f5 Fix status usage ratio (#4584)
1. Removes "Token usage" line for chatgpt sub users
2. Adds the word "used" to the context window line
2025-10-02 10:27:10 -07:00
Marcus Griep
b727d3f98a fix: handle JSON Schema in additionalProperties for MCP tools (#4454)
Fixes #4176

Some common tools provide a schema (even if just an empty object schema)
as the value for `additionalProperties`. The parsing as it currently
stands fails when it encounters this. This PR updates the schema to
accept a schema object in addition to a boolean value, per the JSON
Schema spec.
2025-10-02 13:05:51 -04:00
pakrym-oai
2f6fb37d72 Support CODEX_API_KEY for codex exec (#4615)
Allows to set API key per invocation of `codex exec`
2025-10-02 09:59:45 -07:00
Gabriel Peal
35c76ad47d fix: update the gpt-5-codex prompt to be more explicit that it should always used fenced code blocks info tags (#4569)
We get spurrious reports that the model writes fenced code blocks
without an info tag which then causes auto-language detection in the
extension to incorrectly highlight the code and show the wrong language.
The model should really always include a tag when it can.
2025-10-01 22:41:56 -07:00
pakrym-oai
c07fb71186 Store settings on the thread instead of turn (#4579)
It's much more common to keep the same settings for the entire
conversation, we can add per-turn overrides later.
2025-10-02 00:31:13 +00:00
pakrym-oai
e899ae7d8a Include request ID in the error message (#4572)
To help with issue debugging
<img width="1414" height="253" alt="image"
src="https://github.com/user-attachments/assets/254732df-44ac-4252-997a-6c5e0927355b"
/>
2025-10-01 15:36:04 -07:00
iceweasel-oai
6f97ec4990 canonicalize display of Agents.md paths on Windows. (#4577)
Canonicalize path on Windows to 
- remove unattractive path prefixes such as `\\?\`
- simplify it (`../AGENTS.md` vs
`C:\Users\iceweasel\code\coded\Agents.md`)
before: <img width="1110" height="45" alt="Screenshot 2025-10-01 123520"
src="https://github.com/user-attachments/assets/48920ae6-d89c-41b8-b4ea-df5c18fb5fad"
/>

after: 
<img width="585" height="46" alt="Screenshot 2025-10-01 123612"
src="https://github.com/user-attachments/assets/70a1761a-9d97-4836-b14c-670b6f13e608"
/>
2025-10-01 14:33:19 -07:00
Jeremy Rose
07c1db351a rework patch/exec approval UI (#4573)
| Scenario | Screenshot |
| ---------------------- |
----------------------------------------------------------------------------------------------------------------------------------------------------
|
| short patch | <img width="1096" height="533" alt="short patch"
src="https://github.com/user-attachments/assets/8a883429-0965-4c0b-9002-217b3759b557"
/> |
| short command | <img width="1096" height="533" alt="short command"
src="https://github.com/user-attachments/assets/901abde8-2494-4e86-b98a-7cabaf87ca9c"
/> |
| long patch | <img width="1129" height="892" alt="long patch"
src="https://github.com/user-attachments/assets/fa799a29-a0d6-48e6-b2ef-10302a7916d3"
/> |
| long command | <img width="1096" height="892" alt="long command"
src="https://github.com/user-attachments/assets/11ddf79b-98cb-4b60-ac22-49dfa7779343"
/> |
| viewing complete patch | <img width="1129" height="892" alt="viewing
complete patch"
src="https://github.com/user-attachments/assets/81666958-af94-420e-aa66-b60d0a42b9db"
/> |
2025-10-01 14:29:05 -07:00
pakrym-oai
31102af54b Add initial set of doc comments to the SDK (#4513)
Also perform minor code cleanup.
2025-10-01 13:12:59 -07:00
Thibault Sottiaux
5d78c1edd3 Revert "chore: prompt update to enforce good usage of apply_patch" (#4576)
Reverts openai/codex#3846
2025-10-01 20:11:36 +00:00
pakrym-oai
170c685882 Explicit node imports (#4567)
To help with compatibility
2025-10-01 12:39:04 -07:00
Eric Traut
609f75acec Fix hang on second oauth login attempt (#4568)
This PR fixes a bug that results in a hang in the oauth login flow if a
user logs in, then logs out, then logs in again without first closing
the browser window.

Root cause of problem: We use a local web server for the oauth flow, and
it's implemented using the `tiny_http` rust crate. During the first
login, a socket is created between the browser and the server. The
`tiny_http` library creates worker threads that persist for as long as
this socket remains open. Currently, there's no way to close the
connection on the server side — the library provides no API to do this.
The library also filters all "Connect: close" headers, which makes it
difficult to tell the client browser to close the connection. On the
second login attempt, the browser uses the existing connection rather
than creating a new one. Since that connection is associated with a
server instance that no longer exists, it is effectively ignored.

I considered switching from `tiny_http` to a different web server
library, but that would have been a big change with significant
regression risk. This PR includes a more surgical fix that works around
the limitation of `tiny_http` and sends a "Connect: close" header on the
last "success" page of the oauth flow.
2025-10-01 12:26:28 -07:00
Michael Bolin
eabe18714f fix: use number instead of bigint for the generated TS for RequestId (#4575)
Before this PR:

```typescript
export type RequestId = string | bigint;
```

After:

```typescript
export type RequestId = string | number;
```

`bigint` introduces headaches in TypeScript without providing any real
value.
2025-10-01 12:10:20 -07:00
easong-openai
ceaba36c7f fix ctr-n hint (#4566)
don't show or enable ctr-n to choose best of n while not in the composer
2025-10-01 18:42:04 +00:00
Michael Bolin
d94e8bad8b feat: add --emergency-version-override option to create_github_release script (#4556)
I just had to use this like so:

```
./codex-rs/scripts/create_github_release --publish-alpha --emergency-version-override 0.43.0-alpha.10
```

because the build for `0.43.0-alpha.9` failed:

https://github.com/openai/codex/actions/runs/18167317356
2025-10-01 11:40:04 -07:00
pakrym-oai
8a367ef6bf SDK: support working directory and skipGitRepoCheck options (#4563)
Make options not required, add support for working directory and
skipGitRepoCheck options on the turn
2025-10-01 11:26:49 -07:00
easong-openai
400a5a90bf Fall back to configured instruction files if AGENTS.md isn't available (#4544)
Allow users to configure an agents.md alternative to consume, but warn
the user it may degrade model performance.

Fixes #4376
2025-10-01 18:19:59 +00:00
143 changed files with 4018 additions and 3504 deletions

18
.github/prompts/issue-deduplicator.txt vendored Normal file
View File

@@ -0,0 +1,18 @@
You are an assistant that triages new GitHub issues by identifying potential duplicates.
You will receive the following JSON files located in the current working directory:
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
- `codex-existing-issues.json`: JSON array of recent issues (each element includes number, title, body, createdAt).
Instructions:
- Load both files as JSON and review their contents carefully. The codex-existing-issues.json file is large, ensure you explore all of it.
- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request.
- Only consider an issue a potential duplicate if there is a clear overlap in symptoms, feature requests, reproduction steps, or error messages.
- Prioritize newer issues when similarity is comparable.
- Ignore pull requests and issues whose similarity is tenuous.
- When unsure, prefer returning fewer matches.
Output requirements:
- Respond with a JSON array of issue numbers (integers), ordered from most likely duplicate to least.
- Include at most five numbers.
- If you find no plausible duplicates, respond with `[]`.

26
.github/prompts/issue-labeler.txt vendored Normal file
View File

@@ -0,0 +1,26 @@
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate existing labels for the issue described later in this prompt.
Follow these rules:
- Only pick labels out of the list below.
- Prefer a small set of precise labels over many broad ones.
- If none of the labels fit, respond with an empty JSON array: []
- Output must be a JSON array of label names (strings) with no additional commentary.
Labels to apply:
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. extension — VS Code (or other IDE) extension-specific issues.
4. windows-os — Bugs or friction specific to Windows environments (PowerShell behavior, path handling, copy/paste, OS-specific auth or tooling failures).
5. mcp — Topics involving Model Context Protocol servers/clients.
6. codex-web — Issues targeting the Codex web UI/Cloud experience.
8. azure — Problems or requests tied to Azure OpenAI deployments.
9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
Issue information is available in environment variables:
ISSUE_NUMBER
ISSUE_TITLE
ISSUE_BODY
REPO_FULL_NAME

View File

@@ -0,0 +1,97 @@
name: Issue Deduplicator
on:
issues:
types:
# - opened - disabled while testing
- labeled
jobs:
gather-duplicates:
name: Identify potential duplicates
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
codex_output: ${{ steps.codex.outputs.final_message }}
steps:
- uses: actions/checkout@v4
- name: Prepare Codex inputs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eo pipefail
CURRENT_ISSUE_FILE=codex-current-issue.json
EXISTING_ISSUES_FILE=codex-existing-issues.json
gh issue list --repo "${{ github.repository }}" \
--json number,title,body,createdAt \
--limit 1000 \
--state all \
--search "sort:created-desc" \
| jq '.' \
> "$EXISTING_ISSUES_FILE"
gh issue view "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--json number,title,body \
| jq '.' \
> "$CURRENT_ISSUE_FILE"
- id: codex
uses: openai/codex-action@main
with:
openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }}
prompt_file: .github/prompts/issue-deduplicator.txt
require_repo_write: false
codex_version: 0.43.0-alpha.16
comment-on-issue:
name: Comment with potential duplicates
needs: gather-duplicates
if: ${{ needs.gather-duplicates.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Comment on issue
uses: actions/github-script@v7
env:
CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }}
with:
github-token: ${{ github.token }}
script: |
let numbers;
try {
numbers = JSON.parse(process.env.CODEX_OUTPUT);
} catch (error) {
core.info(`Codex output was not valid JSON. Raw output: ${raw}`);
return;
}
if (numbers.length === 0) {
core.info('Codex reported no potential duplicates.');
return;
}
const lines = ['Potential duplicates detected:', ...numbers.map((value) => `- #${value}`)];
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: lines.join("\n"),
});
- name: Remove codex-deduplicate label
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh issue edit "${{ github.event.issue.number }}" --remove-label codex-deduplicate || true
echo "Attempted to remove label: codex-deduplicate"

78
.github/workflows/issue-labeler.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Issue Labeler
on:
issues:
types:
# - opened - disabled while testing
- labeled
jobs:
gather-labels:
name: Generate label suggestions
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }}
runs-on: ubuntu-latest
permissions:
contents: read
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
REPO_FULL_NAME: ${{ github.repository }}
outputs:
codex_output: ${{ steps.codex.outputs.final_message }}
steps:
- uses: actions/checkout@v4
- id: codex
uses: openai/codex-action@main
with:
openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }}
prompt_file: .github/prompts/issue-labeler.txt
require_repo_write: false
codex_version: 0.43.0-alpha.16
apply-labels:
name: Apply labels from Codex output
needs: gather-labels
if: ${{ needs.gather-labels.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }}
steps:
- name: Apply labels
run: |
json=${CODEX_OUTPUT//$'\r'/}
if [ -z "$json" ]; then
echo "Codex produced no output. Skipping label application."
exit 0
fi
if ! printf '%s' "$json" | jq -e 'type == "array"' >/dev/null 2>&1; then
echo "Codex output was not a JSON array. Raw output: $json"
exit 0
fi
labels=$(printf '%s' "$json" | jq -r '.[] | tostring')
if [ -z "$labels" ]; then
echo "Codex returned an empty array. Nothing to do."
exit 0
fi
cmd=(gh issue edit "$ISSUE_NUMBER")
while IFS= read -r label; do
cmd+=(--add-label "$label")
done <<< "$labels"
"${cmd[@]}" || true
- name: Remove codex-label trigger
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }}
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true
echo "Attempted to remove label: codex-label"

View File

@@ -83,6 +83,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored
- [**Authentication**](./docs/authentication.md)
- [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced)
- [Login on a "Headless" machine](./docs/authentication.md#connecting-on-a-headless-machine)
- [**Non-interactive mode**](./docs/exec.md)
- [**Advanced**](./docs/advanced.md)
- [Non-interactive / CI mode](./docs/advanced.md#non-interactive--ci-mode)
- [Tracing / verbose logging](./docs/advanced.md#tracing--verbose-logging)

11
codex-rs/Cargo.lock generated
View File

@@ -863,6 +863,7 @@ dependencies = [
"codex-rmcp-client",
"core_test_support",
"dirs",
"dunce",
"env-flags",
"escargot",
"eventsource-stream",
@@ -912,7 +913,6 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"codex-arg0",
"codex-common",
@@ -1200,6 +1200,7 @@ dependencies = [
"crossterm",
"diffy",
"dirs",
"dunce",
"image",
"insta",
"itertools 0.14.0",
@@ -1798,6 +1799,12 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dupe"
version = "0.9.1"
@@ -4064,7 +4071,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -97,6 +97,7 @@ derive_more = "2"
diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.4"
env-flags = "0.1.1"
env_logger = "0.11.5"
escargot = "0.5"

View File

@@ -11,6 +11,7 @@ pub const JSONRPC_VERSION: &str = "2.0";
#[serde(untagged)]
pub enum RequestId {
String(String),
#[ts(type = "number")]
Integer(i64),
}

View File

@@ -53,6 +53,7 @@ use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::Cursor as RolloutCursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::NewConversation;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
@@ -708,6 +709,7 @@ impl CodexMessageProcessor {
&self.config.codex_home,
page_size,
cursor_ref,
INTERACTIVE_SESSION_SOURCES,
)
.await
{

View File

@@ -17,6 +17,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use codex_protocol::protocol::SessionSource;
use std::sync::Arc;
pub(crate) struct MessageProcessor {
@@ -34,8 +35,11 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone());
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let auth_manager = AuthManager::shared(config.codex_home.clone(), false);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::VSCode,
));
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
conversation_manager,

View File

@@ -1,4 +1,3 @@
use std::collections::VecDeque;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::AtomicI64;
@@ -48,7 +47,6 @@ pub struct McpProcess {
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
pending_user_messages: VecDeque<JSONRPCNotification>,
}
impl McpProcess {
@@ -119,7 +117,6 @@ impl McpProcess {
process,
stdin,
stdout,
pending_user_messages: VecDeque::new(),
})
}
@@ -378,9 +375,8 @@ impl McpProcess {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
eprintln!("notification: {notification:?}");
self.enqueue_user_message(notification);
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
return jsonrpc_request.try_into().with_context(
@@ -406,9 +402,8 @@ impl McpProcess {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
eprintln!("notification: {notification:?}");
self.enqueue_user_message(notification);
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
@@ -432,9 +427,8 @@ impl McpProcess {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
eprintln!("notification: {notification:?}");
self.enqueue_user_message(notification);
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
@@ -457,10 +451,6 @@ impl McpProcess {
) -> anyhow::Result<JSONRPCNotification> {
eprintln!("in read_stream_until_notification_message({method})");
if let Some(notification) = self.take_pending_notification_by_method(method) {
return Ok(notification);
}
loop {
let message = self.read_jsonrpc_message().await?;
match message {
@@ -468,7 +458,6 @@ impl McpProcess {
if notification.method == method {
return Ok(notification);
}
self.enqueue_user_message(notification);
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
@@ -482,21 +471,4 @@ impl McpProcess {
}
}
}
fn take_pending_notification_by_method(&mut self, method: &str) -> Option<JSONRPCNotification> {
if let Some(pos) = self
.pending_user_messages
.iter()
.position(|notification| notification.method == method)
{
return self.pending_user_messages.remove(pos);
}
None
}
fn enqueue_user_message(&mut self, notification: JSONRPCNotification) {
if notification.method == "codex/event/user_message" {
self.pending_user_messages.push_back(notification);
}
}
}

View File

@@ -8,7 +8,6 @@ use app_test_support::to_response;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
@@ -26,10 +25,6 @@ use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InputMessageKind;
use pretty_assertions::assert_eq;
use std::env;
use tempfile::TempDir;
@@ -372,234 +367,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
}
// Helper: minimal config.toml pointing at mock provider.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() {
if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let tmp = TempDir::new().expect("tmp dir");
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home).expect("create codex home dir");
let workspace_root = tmp.path().join("workspace");
std::fs::create_dir(&workspace_root).expect("create workspace root");
let first_cwd = workspace_root.join("turn1");
let second_cwd = workspace_root.join("turn2");
std::fs::create_dir(&first_cwd).expect("create first cwd");
std::fs::create_dir(&second_cwd).expect("create second cwd");
let responses = vec![
create_shell_sse_response(
vec![
"bash".to_string(),
"-lc".to_string(),
"echo first turn".to_string(),
],
None,
Some(5000),
"call-first",
)
.expect("create first shell response"),
create_final_assistant_message_sse_response("done first")
.expect("create first final assistant message"),
create_shell_sse_response(
vec![
"bash".to_string(),
"-lc".to_string(),
"echo second turn".to_string(),
],
None,
Some(5000),
"call-second",
)
.expect("create second shell response"),
create_final_assistant_message_sse_response("done second")
.expect("create second final assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
create_config_toml(&codex_home, &server.uri()).expect("write config");
let mut mcp = McpProcess::new(&codex_home)
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
cwd: Some(first_cwd.to_string_lossy().into_owned()),
approval_policy: Some(AskForApproval::Never),
sandbox: Some(SandboxMode::WorkspaceWrite),
..Default::default()
})
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let NewConversationResponse {
conversation_id,
model,
..
} = to_response::<NewConversationResponse>(new_conv_resp)
.expect("deserialize newConversation response");
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp");
let first_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![InputItem::Text {
text: "first turn".to_string(),
}],
cwd: first_cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.clone()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
})
.await
.expect("send first sendUserTurn");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)),
)
.await
.expect("sendUserTurn 1 timeout")
.expect("sendUserTurn 1 resp");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_complete 1 timeout")
.expect("task_complete 1 notification");
let second_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![InputItem::Text {
text: "second turn".to_string(),
}],
cwd: second_cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
})
.await
.expect("send second sendUserTurn");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
)
.await
.expect("sendUserTurn 2 timeout")
.expect("sendUserTurn 2 resp");
let mut env_message: Option<String> = None;
let second_cwd_str = second_cwd.to_string_lossy().into_owned();
for _ in 0..10 {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/user_message"),
)
.await
.expect("user_message timeout")
.expect("user_message notification");
let params = notification
.params
.clone()
.expect("user_message should include params");
let event: Event = serde_json::from_value(params).expect("deserialize user_message event");
if let EventMsg::UserMessage(user) = event.msg
&& matches!(user.kind, Some(InputMessageKind::EnvironmentContext))
&& user.message.contains(&second_cwd_str)
{
env_message = Some(user.message);
break;
}
}
let env_message = env_message.expect("expected environment context update");
assert!(
env_message.contains("<sandbox_mode>danger-full-access</sandbox_mode>"),
"env context should reflect new sandbox mode: {env_message}"
);
assert!(
env_message.contains("<network_access>enabled</network_access>"),
"env context should enable network access for danger-full-access policy: {env_message}"
);
assert!(
env_message.contains(&second_cwd_str),
"env context should include updated cwd: {env_message}"
);
let exec_begin_notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/exec_command_begin"),
)
.await
.expect("exec_command_begin timeout")
.expect("exec_command_begin notification");
let params = exec_begin_notification
.params
.clone()
.expect("exec_command_begin params");
let event: Event = serde_json::from_value(params).expect("deserialize exec begin event");
let exec_begin = match event.msg {
EventMsg::ExecCommandBegin(exec_begin) => exec_begin,
other => panic!("expected ExecCommandBegin event, got {other:?}"),
};
assert_eq!(
exec_begin.cwd, second_cwd,
"exec turn should run from updated cwd"
);
assert_eq!(
exec_begin.command,
vec![
"bash".to_string(),
"-lc".to_string(),
"echo second turn".to_string()
],
"exec turn should run expected command"
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_complete 2 timeout")
.expect("task_complete 2 notification");
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(

View File

@@ -190,7 +190,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
// Require ChatGPT login (SWIC). Exit with a clear message if missing.
let _token = match codex_core::config::find_codex_home()
.ok()
.map(codex_login::AuthManager::new)
.map(|home| codex_login::AuthManager::new(home, false))
.and_then(|am| am.auth())
{
Some(auth) => {
@@ -839,6 +839,9 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
&& matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N'))
|| matches!(key.code, KeyCode::Char('\u{000E}'));
if is_ctrl_n {
if app.new_task.is_none() {
continue;
}
if app.best_of_modal.is_some() {
app.best_of_modal = None;
needs_redraw = true;

View File

@@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
if app.new_task.is_some() {
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
@@ -1004,32 +1004,40 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
const MAX_WIDTH: u16 = 40;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 12;
const MIN_HEIGHT: u16 = 6;
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
let modal_height = inner
.height
.min(MAX_HEIGHT)
.max(inner.height.min(MIN_HEIGHT));
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
frame.render_widget(Clear, modal_area);
frame.render_widget(block.clone(), modal_area);
let content = overlay_content(modal_area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let hint = Paragraph::new(Line::from(
"Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
.wrap(Wrap { trim: true });
frame.render_widget(hint, rows[0]);
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let options = [1usize, 2, 3, 4];
let mut items: Vec<ListItem> = Vec::new();
for &attempts in &options {
let mut spans: Vec<ratatui::text::Span> =
vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()];
let noun = if attempts == 1 { "attempt" } else { "attempts" };
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
spans.push(" ".into());
spans.push(format!("{attempts}x parallel").dim());
if attempts == app.best_of_n {

View File

@@ -70,7 +70,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Ok(home) = codex_core::config::find_codex_home() {
let am = codex_login::AuthManager::new(home);
let am = codex_login::AuthManager::new(home, false);
if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()

View File

@@ -20,49 +20,49 @@ const PRESETS: &[ModelPreset] = &[
ModelPreset {
id: "gpt-5-codex-low",
label: "gpt-5-codex low",
description: "",
description: "Fastest responses with limited reasoning",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-codex-medium",
label: "gpt-5-codex medium",
description: "",
description: "Dynamically adjusts reasoning based on the task",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-codex-high",
label: "gpt-5-codex high",
description: "",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5-minimal",
label: "gpt-5 minimal",
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
description: "Fastest responses with little reasoning",
model: "gpt-5",
effort: Some(ReasoningEffort::Minimal),
},
ModelPreset {
id: "gpt-5-low",
label: "gpt-5 low",
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-medium",
label: "gpt-5 medium",
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-high",
label: "gpt-5 high",
description: "— maximizes reasoning depth for complex or ambiguous problems",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5",
effort: Some(ReasoningEffort::High),
},

View File

@@ -27,6 +27,7 @@ codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-otel = { workspace = true, features = ["otel"] }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }

View File

@@ -5,7 +5,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}).
## Editing constraints
@@ -90,7 +89,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; add a language hint whenever obvious.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.

View File

@@ -27,7 +27,6 @@ pub(crate) enum InternalApplyPatchInvocation {
DelegateToExec(ApplyPatchExec),
}
#[derive(Debug)]
pub(crate) struct ApplyPatchExec {
pub(crate) action: ApplyPatchAction,
pub(crate) user_explicitly_approved_this_action: bool,
@@ -110,28 +109,3 @@ pub(crate) fn convert_apply_patch_to_protocol(
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn convert_apply_patch_maps_add_variant() {
let tmp = tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
// Create an action with a single Add change
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let got = convert_apply_patch_to_protocol(&action);
assert_eq!(
got.get(&p),
Some(&FileChange::Add {
content: "hello".to_string()
})
);
}
}

View File

@@ -73,7 +73,7 @@ impl CodexAuth {
/// Loads the available auth information from the auth.json.
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home)
load_auth(codex_home, false)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -188,6 +188,7 @@ impl CodexAuth {
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
pub fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
@@ -196,6 +197,13 @@ pub fn read_openai_api_key_from_env() -> Option<String> {
.filter(|value| !value.is_empty())
}
pub fn read_codex_api_key_from_env() -> Option<String> {
env::var(CODEX_API_KEY_ENV_VAR)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
@@ -221,7 +229,18 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<(
write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
}
fn load_auth(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
) -> std::io::Result<Option<CodexAuth>> {
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
let client = crate::default_client::create_client();
return Ok(Some(CodexAuth::from_api_key_with_client(
api_key.as_str(),
client,
)));
}
let auth_file = get_auth_file(codex_home);
let client = crate::default_client::create_client();
let auth_dot_json = match try_read_auth_json(&auth_file) {
@@ -455,7 +474,7 @@ mod tests {
auth_dot_json,
auth_file: _,
..
} = super::load_auth(codex_home.path()).unwrap().unwrap();
} = super::load_auth(codex_home.path(), false).unwrap().unwrap();
assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode);
@@ -494,7 +513,7 @@ mod tests {
)
.unwrap();
let auth = super::load_auth(dir.path()).unwrap().unwrap();
let auth = super::load_auth(dir.path(), false).unwrap().unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
@@ -577,6 +596,7 @@ mod tests {
pub struct AuthManager {
codex_home: PathBuf,
inner: RwLock<CachedAuth>,
enable_codex_api_key_env: bool,
}
impl AuthManager {
@@ -584,11 +604,14 @@ impl AuthManager {
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten();
pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self {
let auth = load_auth(&codex_home, enable_codex_api_key_env)
.ok()
.flatten();
Self {
codex_home,
inner: RwLock::new(CachedAuth { auth }),
enable_codex_api_key_env,
}
}
@@ -598,6 +621,7 @@ impl AuthManager {
Arc::new(Self {
codex_home: PathBuf::new(),
inner: RwLock::new(cached),
enable_codex_api_key_env: false,
})
}
@@ -609,7 +633,9 @@ impl AuthManager {
/// Force a reload of the auth information from auth.json. Returns
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten();
let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env)
.ok()
.flatten();
if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth;
@@ -628,8 +654,8 @@ impl AuthManager {
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf) -> Arc<Self> {
Arc::new(Self::new(codex_home))
pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc<Self> {
Arc::new(Self::new(codex_home, enable_codex_api_key_env))
}
/// Attempt to refresh the current auth token (if any). On success, reload

View File

@@ -6,6 +6,8 @@ use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
@@ -320,11 +322,18 @@ pub(crate) async fn stream_chat_completions(
let status = res.status();
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
let body = (res.text().await).unwrap_or_default();
return Err(CodexErr::UnexpectedStatus(status, body));
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
if attempt > max_retries {
return Err(CodexErr::RetryLimit(status));
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
}
let retry_after_secs = res

View File

@@ -5,6 +5,8 @@ use std::time::Duration;
use crate::AuthManager;
use crate::auth::CodexAuth;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use bytes::Bytes;
use codex_app_server_protocol::AuthMode;
use codex_protocol::ConversationId;
@@ -307,14 +309,17 @@ impl ModelClient {
.log_request(attempt, || req_builder.send())
.await;
let mut request_id = None;
if let Ok(resp) = &res {
request_id = resp
.headers()
.get("cf-ray")
.map(|v| v.to_str().unwrap_or_default().to_string());
trace!(
"Response status: {}, cf-ray: {}",
"Response status: {}, cf-ray: {:?}",
resp.status(),
resp.headers()
.get("cf-ray")
.map(|v| v.to_str().unwrap_or_default())
.unwrap_or_default()
request_id
);
}
@@ -374,7 +379,11 @@ impl ModelClient {
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
let body = res.text().await.unwrap_or_default();
return Err(StreamAttemptError::Fatal(CodexErr::UnexpectedStatus(
status, body,
UnexpectedResponseError {
status,
body,
request_id: None,
},
)));
}
@@ -383,25 +392,6 @@ impl ModelClient {
let body = res.json::<ErrorResponse>().await.ok();
if let Some(ErrorResponse { error }) = body {
if error.r#type.as_deref() == Some("usage_limit_reached") {
let context = "usage_limit_reached";
if Self::refresh_on_plan_mismatch(
auth_manager,
&auth,
error.plan_type.clone(),
context,
)
.await
{
return Err(StreamAttemptError::RetryableTransportError(
CodexErr::Stream(
format!(
"plan mismatch detected during {context}; retrying"
),
None,
),
));
}
// Prefer the plan_type provided in the error message if present
// because it's more up to date than the one encoded in the auth
// token.
@@ -416,24 +406,6 @@ impl ModelClient {
});
return Err(StreamAttemptError::Fatal(codex_err));
} else if error.r#type.as_deref() == Some("usage_not_included") {
let context = "usage_not_included";
if Self::refresh_on_plan_mismatch(
auth_manager,
&auth,
error.plan_type.clone(),
context,
)
.await
{
return Err(StreamAttemptError::RetryableTransportError(
CodexErr::Stream(
format!(
"plan mismatch detected during {context}; retrying"
),
None,
),
));
}
return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded));
}
}
@@ -442,54 +414,13 @@ impl ModelClient {
Err(StreamAttemptError::RetryableHttpError {
status,
retry_after,
request_id,
})
}
Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())),
}
}
async fn refresh_on_plan_mismatch(
auth_manager: &Option<Arc<AuthManager>>,
auth: &Option<CodexAuth>,
server_plan_type: Option<PlanType>,
log_context: &str,
) -> bool {
let Some(server_plan) = server_plan_type else {
return false;
};
let jwt_plan = auth.as_ref().and_then(CodexAuth::get_plan_type);
if jwt_plan == Some(server_plan.clone()) {
return false;
}
if let Some(manager) = auth_manager.as_ref() {
match manager.refresh_token().await {
Ok(_) => {
warn!(
context = log_context,
?server_plan,
previous_plan = ?jwt_plan,
"plan mismatch detected; refreshed token and will retry"
);
return true;
}
Err(err) => {
warn!(
context = log_context,
?server_plan,
previous_plan = ?jwt_plan,
error = ?err,
"failed to refresh token after plan mismatch"
);
}
}
}
false
}
pub fn get_provider(&self) -> ModelProviderInfo {
self.provider.clone()
}
@@ -527,6 +458,7 @@ enum StreamAttemptError {
RetryableHttpError {
status: StatusCode,
retry_after: Option<Duration>,
request_id: Option<String>,
},
RetryableTransportError(CodexErr),
Fatal(CodexErr),
@@ -551,11 +483,13 @@ impl StreamAttemptError {
fn into_error(self) -> CodexErr {
match self {
Self::RetryableHttpError { status, .. } => {
Self::RetryableHttpError {
status, request_id, ..
} => {
if status == StatusCode::INTERNAL_SERVER_ERROR {
CodexErr::InternalServerError
} else {
CodexErr::RetryLimit(status)
CodexErr::RetryLimit(RetryLimitReachedError { status, request_id })
}
}
Self::RetryableTransportError(error) => error,
@@ -1405,32 +1339,4 @@ mod tests {
let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type");
assert_eq!(plan_json, "\"vip\"");
}
#[tokio::test]
async fn refresh_on_plan_mismatch_retries_when_plan_differs() {
use crate::token_data::KnownPlan;
use crate::token_data::PlanType;
use std::path::PathBuf;
use std::sync::Arc;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
if let Some(auth_json) = auth.auth_dot_json.lock().unwrap().as_mut()
&& let Some(tokens) = auth_json.tokens.as_mut()
{
tokens.id_token.chatgpt_plan_type = Some(PlanType::Known(KnownPlan::Plus));
tokens.id_token.raw_jwt = "dummy".to_string();
}
let manager = Arc::new(AuthManager::new(PathBuf::new()));
let should_retry = ModelClient::refresh_on_plan_mismatch(
&Some(manager),
&Some(auth),
Some(PlanType::Known(KnownPlan::Team)),
"usage_limit_reached",
)
.await;
assert!(should_retry);
}
}

View File

@@ -1,9 +1,11 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::time::Duration;
use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
@@ -22,6 +24,7 @@ use codex_protocol::protocol::ConversationPathResponseEvent;
use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TaskStartedEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
@@ -42,6 +45,7 @@ use tracing::warn;
use crate::ModelProviderInfo;
use crate::apply_patch;
use crate::apply_patch::ApplyPatchExec;
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
use crate::apply_patch::InternalApplyPatchInvocation;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::client::ModelClient;
@@ -54,21 +58,19 @@ use crate::environment_context::EnvironmentContext;
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;
use crate::exec::StdoutStream;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::exec_command::EXEC_COMMAND_TOOL_NAME;
use crate::exec_command::ExecCommandParams;
use crate::exec_command::ExecSessionManager;
use crate::exec_command::WRITE_STDIN_TOOL_NAME;
use crate::exec_command::WriteStdinParams;
use crate::exec_env::create_env;
use crate::executor::ExecutionMode;
use crate::executor::Executor;
use crate::executor::ExecutorConfig;
use crate::executor::normalize_exec_result;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::model_family::find_family_for_model;
@@ -109,9 +111,13 @@ use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
use crate::protocol::ViewImageToolCallEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
@@ -124,6 +130,7 @@ use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_otel::otel_event_manager::ToolDecisionSource;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -171,6 +178,7 @@ impl Codex {
config: Config,
auth_manager: Arc<AuthManager>,
conversation_history: InitialHistory,
session_source: SessionSource,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -199,6 +207,7 @@ impl Codex {
auth_manager.clone(),
tx_event.clone(),
conversation_history,
session_source,
)
.await
.map_err(|e| {
@@ -333,6 +342,7 @@ impl Session {
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
initial_history: InitialHistory,
session_source: SessionSource,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let ConfigureSession {
provider,
@@ -356,7 +366,11 @@ impl Session {
let conversation_id = ConversationId::default();
(
conversation_id,
RolloutRecorderParams::new(conversation_id, user_instructions.clone()),
RolloutRecorderParams::new(
conversation_id,
user_instructions.clone(),
session_source,
),
)
}
InitialHistory::Resumed(resumed_history) => (
@@ -481,13 +495,9 @@ impl Session {
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: notify,
rollout: Mutex::new(Some(rollout_recorder)),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
user_shell: default_shell,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
executor: Executor::new(ExecutorConfig::new(
turn_context.sandbox_policy.clone(),
turn_context.cwd.clone(),
config.codex_linux_sandbox_exe.clone(),
)),
};
let sess = Arc::new(Session {
@@ -572,11 +582,6 @@ impl Session {
}
}
/// Emit an exec approval request event and await the user's decision.
///
/// The request is keyed by `sub_id`/`call_id` so matching responses are delivered
/// to the correct in-flight turn. If the task is aborted, this returns the
/// default `ReviewDecision` (`Denied`).
pub async fn request_command_approval(
&self,
sub_id: String,
@@ -674,6 +679,11 @@ impl Session {
}
}
pub async fn add_approved_command(&self, cmd: Vec<String>) {
let mut state = self.state.lock().await;
state.add_approved_command(cmd);
}
/// Records input items: always append to conversation history and
/// persist these response items to rollout.
async fn record_conversation_items(&self, items: &[ResponseItem]) {
@@ -831,7 +841,6 @@ impl Session {
command_for_display,
cwd,
apply_patch,
..
} = exec_command_context;
let msg = match apply_patch {
Some(ApplyPatchCommandContext {
@@ -928,29 +937,45 @@ impl Session {
/// command even on error.
///
/// Returns the output of the exec tool call.
async fn run_exec_with_events(
async fn run_exec_with_events<'a>(
&self,
turn_diff_tracker: &mut TurnDiffTracker,
prepared: PreparedExec,
approval_policy: AskForApproval,
) -> Result<ExecToolCallOutput, ExecError> {
let PreparedExec { context, request } = prepared;
let is_apply_patch = context.apply_patch.is_some();
let sub_id = context.sub_id.clone();
let call_id = context.call_id.clone();
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, context.clone())
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
.await;
let result = self
.services
.executor
.run(request, self, approval_policy, &context)
.await;
let normalized = normalize_exec_result(&result);
let borrowed = normalized.event_output();
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
exec_args.sandbox_policy,
exec_args.sandbox_cwd,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
)
.await;
let output_stderr;
let borrowed: &ExecToolCallOutput = match &result {
Ok(output) => output,
Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output,
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(get_error_message_ui(e)),
aggregated_output: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
timed_out: false,
};
&output_stderr
}
};
self.on_exec_command_end(
turn_diff_tracker,
&sub_id,
@@ -960,15 +985,13 @@ impl Session {
)
.await;
drop(normalized);
result
}
/// Helper that emits a BackgroundEvent with the given message. This keeps
/// the callsites terse so adding more diagnostics does not clutter the
/// core agent logic.
pub(crate) async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
@@ -1056,7 +1079,7 @@ impl Session {
&self.services.notifier
}
pub(crate) fn user_shell(&self) -> &shell::Shell {
fn user_shell(&self) -> &shell::Shell {
&self.services.user_shell
}
@@ -1078,8 +1101,6 @@ pub(crate) struct ExecCommandContext {
pub(crate) command_for_display: Vec<String>,
pub(crate) cwd: PathBuf,
pub(crate) apply_patch: Option<ApplyPatchCommandContext>,
pub(crate) tool_name: String,
pub(crate) otel_event_manager: OtelEventManager,
}
#[derive(Clone, Debug)]
@@ -1286,19 +1307,8 @@ async fn submission_loop(
let previous_env_context = EnvironmentContext::from(turn_context.as_ref());
let new_env_context = EnvironmentContext::from(&fresh_turn_context);
if !new_env_context.equals_except_shell(&previous_env_context) {
let env_response_item = ResponseItem::from(new_env_context);
sess.record_conversation_items(std::slice::from_ref(&env_response_item))
sess.record_conversation_items(&[ResponseItem::from(new_env_context)])
.await;
for msg in map_response_item_to_event_messages(
&env_response_item,
sess.show_raw_agent_reasoning(),
) {
let event = Event {
id: sub.id.clone(),
msg,
};
sess.send_event(event).await;
}
}
// Install the new persistent context for subsequent tasks/turns.
@@ -2469,13 +2479,21 @@ async fn handle_function_call(
))
})?;
let abs = turn_context.resolve_path(Some(args.path));
sess.inject_input(vec![InputItem::LocalImage { path: abs }])
sess.inject_input(vec![InputItem::LocalImage { path: abs.clone() }])
.await
.map_err(|_| {
FunctionCallError::RespondToModel(
"unable to attach image (no active task)".to_string(),
)
})?;
sess.send_event(Event {
id: sub_id.clone(),
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
call_id: call_id.clone(),
path: abs,
}),
})
.await;
Ok("attached local image path".to_string())
}
@@ -2609,6 +2627,33 @@ fn parse_container_exec_arguments(
})
}
pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
pub sandbox_policy: &'a SandboxPolicy,
pub sandbox_cwd: &'a Path,
pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
pub stdout_stream: Option<StdoutStream>,
}
fn maybe_translate_shell_command(
params: ExecParams,
sess: &Session,
turn_context: &TurnContext,
) -> ExecParams {
let should_translate = matches!(sess.user_shell(), crate::shell::Shell::PowerShell(_))
|| turn_context.shell_environment_policy.use_profile;
if should_translate
&& let Some(command) = sess
.user_shell()
.format_default_shell_invocation(params.command.clone())
{
return ExecParams { command, ..params };
}
params
}
async fn handle_container_exec_with_params(
tool_name: &str,
params: ExecParams,
@@ -2654,10 +2699,152 @@ async fn handle_container_exec_with_params(
MaybeApplyPatchVerified::NotApplyPatch => None,
};
let command_for_display = if let Some(exec) = apply_patch_exec.as_ref() {
vec!["apply_patch".to_string(), exec.action.patch.clone()]
} else {
params.command.clone()
let (params, safety, command_for_display) = match &apply_patch_exec {
Some(ApplyPatchExec {
action: ApplyPatchAction { patch, cwd, .. },
user_explicitly_approved_this_action,
}) => {
let path_to_codex = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().to_string());
let Some(path_to_codex) = path_to_codex else {
return Err(FunctionCallError::RespondToModel(
"failed to determine path to codex executable".to_string(),
));
};
let params = ExecParams {
command: vec![
path_to_codex,
CODEX_APPLY_PATCH_ARG1.to_string(),
patch.clone(),
],
cwd: cwd.clone(),
timeout_ms: params.timeout_ms,
env: HashMap::new(),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification.clone(),
};
let safety = if *user_explicitly_approved_this_action {
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved: true,
}
} else {
assess_safety_for_untrusted_command(
turn_context.approval_policy,
&turn_context.sandbox_policy,
params.with_escalated_permissions.unwrap_or(false),
)
};
(
params,
safety,
vec!["apply_patch".to_string(), patch.clone()],
)
}
None => {
let safety = {
let state = sess.state.lock().await;
assess_command_safety(
&params.command,
turn_context.approval_policy,
&turn_context.sandbox_policy,
state.approved_commands_ref(),
params.with_escalated_permissions.unwrap_or(false),
)
};
let command_for_display = params.command.clone();
(params, safety, command_for_display)
}
};
let sandbox_type = match safety {
SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved,
} => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::Approved,
if user_explicitly_approved {
ToolDecisionSource::User
} else {
ToolDecisionSource::Config
},
);
sandbox_type
}
SafetyCheck::AskUser => {
let decision = sess
.request_command_approval(
sub_id.clone(),
call_id.clone(),
params.command.clone(),
params.cwd.clone(),
params.justification.clone(),
)
.await;
match decision {
ReviewDecision::Approved => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::Approved,
ToolDecisionSource::User,
);
}
ReviewDecision::ApprovedForSession => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::ApprovedForSession,
ToolDecisionSource::User,
);
sess.add_approved_command(params.command.clone()).await;
}
ReviewDecision::Denied => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::Denied,
ToolDecisionSource::User,
);
return Err(FunctionCallError::RespondToModel(
"exec command rejected by user".to_string(),
));
}
ReviewDecision::Abort => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::Abort,
ToolDecisionSource::User,
);
return Err(FunctionCallError::RespondToModel(
"exec command aborted by user".to_string(),
));
}
}
// No sandboxing is applied because the user has given
// explicit approval. Often, we end up in this case because
// the command cannot be run in a sandbox, such as
// installing a new dependency that requires network access.
SandboxType::None
}
SafetyCheck::Reject { reason } => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
ReviewDecision::Denied,
ToolDecisionSource::Config,
);
return Err(FunctionCallError::RespondToModel(format!(
"exec command rejected: {reason:?}"
)));
}
};
let exec_command_context = ExecCommandContext {
@@ -2665,47 +2852,38 @@ async fn handle_container_exec_with_params(
call_id: call_id.clone(),
command_for_display: command_for_display.clone(),
cwd: params.cwd.clone(),
apply_patch: apply_patch_exec.as_ref().map(
apply_patch: apply_patch_exec.map(
|ApplyPatchExec {
action,
user_explicitly_approved_this_action,
}| ApplyPatchCommandContext {
user_explicitly_approved_this_action: *user_explicitly_approved_this_action,
changes: convert_apply_patch_to_protocol(action),
user_explicitly_approved_this_action,
changes: convert_apply_patch_to_protocol(&action),
},
),
tool_name: tool_name.to_string(),
otel_event_manager,
};
let mode = match apply_patch_exec {
Some(exec) => ExecutionMode::ApplyPatch(exec),
None => ExecutionMode::Shell,
};
sess.services.executor.update_environment(
turn_context.sandbox_policy.clone(),
turn_context.cwd.clone(),
);
let prepared_exec = PreparedExec::new(
exec_command_context,
params,
command_for_display,
mode,
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
turn_context.shell_environment_policy.use_profile,
);
let params = maybe_translate_shell_command(params, sess, turn_context);
let output_result = sess
.run_exec_with_events(
turn_diff_tracker,
prepared_exec,
turn_context.approval_policy,
exec_command_context.clone(),
ExecInvokeArgs {
params: params.clone(),
sandbox_type,
sandbox_policy: &turn_context.sandbox_policy,
sandbox_cwd: &turn_context.cwd,
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
})
},
},
)
.await;
@@ -2719,16 +2897,154 @@ async fn handle_container_exec_with_params(
Err(FunctionCallError::RespondToModel(content))
}
}
Err(ExecError::Function(err)) => Err(err),
Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => Err(
FunctionCallError::RespondToModel(format_exec_output(&output)),
),
Err(ExecError::Codex(err)) => Err(FunctionCallError::RespondToModel(format!(
"execution error: {err:?}"
Err(CodexErr::Sandbox(error)) => {
handle_sandbox_error(
tool_name,
turn_diff_tracker,
params,
exec_command_context,
error,
sandbox_type,
sess,
turn_context,
&otel_event_manager,
)
.await
}
Err(e) => Err(FunctionCallError::RespondToModel(format!(
"execution error: {e:?}"
))),
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_sandbox_error(
tool_name: &str,
turn_diff_tracker: &mut TurnDiffTracker,
params: ExecParams,
exec_command_context: ExecCommandContext,
error: SandboxErr,
sandbox_type: SandboxType,
sess: &Session,
turn_context: &TurnContext,
otel_event_manager: &OtelEventManager,
) -> Result<String, FunctionCallError> {
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();
if let SandboxErr::Timeout { output } = &error {
let content = format_exec_output(output);
return Err(FunctionCallError::RespondToModel(content));
}
// Early out if either the user never wants to be asked for approval, or
// we're letting the model manage escalation requests. Otherwise, continue
match turn_context.approval_policy {
AskForApproval::Never | AskForApproval::OnRequest => {
return Err(FunctionCallError::RespondToModel(format!(
"failed in sandbox {sandbox_type:?} with execution error: {error:?}"
)));
}
AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (),
}
// Note that when `error` is `SandboxErr::Denied`, it could be a false
// positive. That is, it may have exited with a non-zero exit code, not
// because the sandbox denied it, but because that is its expected behavior,
// i.e., a grep command that did not match anything. Ideally we would
// include additional metadata on the command to indicate whether non-zero
// exit codes merit a retry.
// For now, we categorically ask the user to retry without sandbox and
// emit the raw error as a background event.
sess.notify_background_event(&sub_id, format!("Execution failed: {error}"))
.await;
let decision = sess
.request_command_approval(
sub_id.clone(),
call_id.clone(),
params.command.clone(),
cwd.clone(),
Some("command failed; retry without sandbox?".to_string()),
)
.await;
match decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
// Persist this command as preapproved for the
// remainder of the session so future
// executions skip the sandbox directly.
// TODO(ragona): Isn't this a bug? It always saves the command in an | fork?
sess.add_approved_command(params.command.clone()).await;
// Inform UI we are retrying without sandbox.
sess.notify_background_event(&sub_id, "retrying command without sandbox")
.await;
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
decision,
ToolDecisionSource::User,
);
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
let retry_output_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context.clone(),
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
sandbox_policy: &turn_context.sandbox_policy,
sandbox_cwd: &turn_context.cwd,
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
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) => {
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let content = format_exec_output(&retry_output);
if *exit_code == 0 {
Ok(content)
} else {
Err(FunctionCallError::RespondToModel(content))
}
}
Err(e) => Err(FunctionCallError::RespondToModel(format!(
"retry failed: {e}"
))),
}
}
decision @ (ReviewDecision::Denied | ReviewDecision::Abort) => {
otel_event_manager.tool_decision(
tool_name,
call_id.as_str(),
decision,
ToolDecisionSource::User,
);
// Fall through to original failure handling.
Err(FunctionCallError::RespondToModel(
"exec command rejected by user".to_string(),
))
}
}
}
fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
let ExecToolCallOutput {
aggregated_output, ..
@@ -2987,8 +3303,6 @@ pub(crate) async fn exit_review_mode(
.await;
}
use crate::executor::errors::ExecError;
use crate::executor::linkers::PreparedExec;
#[cfg(test)]
pub(crate) use tests::make_session_and_context;
@@ -3302,13 +3616,9 @@ mod tests {
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::default(),
rollout: Mutex::new(None),
codex_linux_sandbox_exe: None,
user_shell: shell::Shell::Unknown,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
executor: Executor::new(ExecutorConfig::new(
turn_context.sandbox_policy.clone(),
turn_context.cwd.clone(),
None,
)),
};
let session = Session {
conversation_id,
@@ -3375,13 +3685,9 @@ mod tests {
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::default(),
rollout: Mutex::new(None),
codex_linux_sandbox_exe: None,
user_shell: shell::Shell::Unknown,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
executor: Executor::new(ExecutorConfig::new(
config.sandbox_policy.clone(),
config.cwd.clone(),
None,
)),
};
let session = Arc::new(Session {
conversation_id,

View File

@@ -141,6 +141,9 @@ pub struct Config {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
/// Additional filenames to try when looking for project-level docs.
pub project_doc_fallback_filenames: Vec<String>,
/// Directory containing all Codex state (defaults to `~/.codex` but can be
/// overridden by the `CODEX_HOME` environment variable).
pub codex_home: PathBuf,
@@ -670,6 +673,9 @@ pub struct ConfigToml {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -1038,6 +1044,19 @@ impl Config {
mcp_servers: cfg.mcp_servers,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
project_doc_fallback_filenames: cfg
.project_doc_fallback_filenames
.unwrap_or_default()
.into_iter()
.filter_map(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect(),
codex_home,
history,
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
@@ -1811,6 +1830,7 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -1871,6 +1891,7 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -1946,6 +1967,7 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -2007,6 +2029,7 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,

View File

@@ -17,6 +17,7 @@ use codex_protocol::ConversationId;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -35,20 +36,25 @@ pub struct NewConversation {
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager,
session_source,
}
}
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub fn with_auth(auth: CodexAuth) -> Self {
Self::new(crate::AuthManager::from_auth_for_testing(auth))
Self::new(
crate::AuthManager::from_auth_for_testing(auth),
SessionSource::Exec,
)
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
@@ -64,7 +70,13 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(config, auth_manager, InitialHistory::New).await?;
} = Codex::spawn(
config,
auth_manager,
InitialHistory::New,
self.session_source,
)
.await?;
self.finalize_spawn(codex, conversation_id).await
}
@@ -121,7 +133,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(config, auth_manager, initial_history).await?;
} = Codex::spawn(config, auth_manager, initial_history, self.session_source).await?;
self.finalize_spawn(codex, conversation_id).await
}
@@ -155,7 +167,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(config, auth_manager, history).await?;
} = Codex::spawn(config, auth_manager, history, self.session_source).await?;
self.finalize_spawn(codex, conversation_id).await
}

View File

@@ -76,8 +76,8 @@ pub enum CodexErr {
Interrupted,
/// Unexpected HTTP status code.
#[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String),
#[error("{0}")]
UnexpectedStatus(UnexpectedResponseError),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
@@ -91,8 +91,8 @@ pub enum CodexErr {
InternalServerError,
/// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),
#[error("{0}")]
RetryLimit(RetryLimitReachedError),
/// Agent loop died unexpectedly
#[error("internal error; agent loop died unexpectedly")]
@@ -135,6 +135,49 @@ pub enum CodexErr {
EnvVar(EnvVarError),
}
#[derive(Debug)]
pub struct UnexpectedResponseError {
pub status: StatusCode,
pub body: String,
pub request_id: Option<String>,
}
impl std::fmt::Display for UnexpectedResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
impl std::error::Error for UnexpectedResponseError {}
#[derive(Debug)]
pub struct RetryLimitReachedError {
pub status: StatusCode,
pub request_id: Option<String>,
}
impl std::fmt::Display for RetryLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"exceeded retry limit, last status: {}{}",
self.status,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub(crate) plan_type: Option<PlanType>,

View File

@@ -49,7 +49,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}
@@ -92,7 +92,7 @@ Can write control characters (\u0003 for Ctrl-C), or an empty string to just pol
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string(), "chars".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}

View File

@@ -1,101 +0,0 @@
use std::collections::HashMap;
use std::env;
use async_trait::async_trait;
use crate::CODEX_APPLY_PATCH_ARG1;
use crate::apply_patch::ApplyPatchExec;
use crate::exec::ExecParams;
use crate::function_tool::FunctionCallError;
pub(crate) enum ExecutionMode {
Shell,
ApplyPatch(ApplyPatchExec),
}
#[async_trait]
/// Backend-specific hooks that prepare and post-process execution requests for a
/// given [`ExecutionMode`].
pub(crate) trait ExecutionBackend: Send + Sync {
fn prepare(
&self,
params: ExecParams,
// Required for downcasting the apply_patch.
mode: &ExecutionMode,
) -> Result<ExecParams, FunctionCallError>;
fn stream_stdout(&self, _mode: &ExecutionMode) -> bool {
true
}
}
static SHELL_BACKEND: ShellBackend = ShellBackend;
static APPLY_PATCH_BACKEND: ApplyPatchBackend = ApplyPatchBackend;
pub(crate) fn backend_for_mode(mode: &ExecutionMode) -> &'static dyn ExecutionBackend {
match mode {
ExecutionMode::Shell => &SHELL_BACKEND,
ExecutionMode::ApplyPatch(_) => &APPLY_PATCH_BACKEND,
}
}
struct ShellBackend;
#[async_trait]
impl ExecutionBackend for ShellBackend {
fn prepare(
&self,
params: ExecParams,
mode: &ExecutionMode,
) -> Result<ExecParams, FunctionCallError> {
match mode {
ExecutionMode::Shell => Ok(params),
_ => Err(FunctionCallError::RespondToModel(
"shell backend invoked with non-shell mode".to_string(),
)),
}
}
}
struct ApplyPatchBackend;
#[async_trait]
impl ExecutionBackend for ApplyPatchBackend {
fn prepare(
&self,
params: ExecParams,
mode: &ExecutionMode,
) -> Result<ExecParams, FunctionCallError> {
match mode {
ExecutionMode::ApplyPatch(exec) => {
let path_to_codex = env::current_exe()
.ok()
.map(|p| p.to_string_lossy().to_string())
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"failed to determine path to codex executable".to_string(),
)
})?;
let patch = exec.action.patch.clone();
Ok(ExecParams {
command: vec![path_to_codex, CODEX_APPLY_PATCH_ARG1.to_string(), patch],
cwd: exec.action.cwd.clone(),
timeout_ms: params.timeout_ms,
// Run apply_patch with a minimal environment for determinism and to
// avoid leaking host environment variables into the patch process.
env: HashMap::new(),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification,
})
}
ExecutionMode::Shell => Err(FunctionCallError::RespondToModel(
"apply_patch backend invoked without patch context".to_string(),
)),
}
}
fn stream_stdout(&self, _mode: &ExecutionMode) -> bool {
false
}
}

View File

@@ -1,51 +0,0 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Clone, Debug, Default)]
/// Thread-safe store of user approvals so repeated commands can reuse
/// previously granted trust.
pub(crate) struct ApprovalCache {
inner: Arc<Mutex<HashSet<Vec<String>>>>,
}
impl ApprovalCache {
pub(crate) fn insert(&self, command: Vec<String>) {
if command.is_empty() {
return;
}
if let Ok(mut guard) = self.inner.lock() {
guard.insert(command);
}
}
pub(crate) fn snapshot(&self) -> HashSet<Vec<String>> {
self.inner.lock().map(|g| g.clone()).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn insert_ignores_empty_and_dedupes() {
let cache = ApprovalCache::default();
// Empty should be ignored
cache.insert(vec![]);
assert!(cache.snapshot().is_empty());
// Insert a command and verify snapshot contains it
let cmd = vec!["foo".to_string(), "bar".to_string()];
cache.insert(cmd.clone());
let snap1 = cache.snapshot();
assert!(snap1.contains(&cmd));
// Reinserting should not create duplicates
cache.insert(cmd);
let snap2 = cache.snapshot();
assert_eq!(snap1, snap2);
}
}

View File

@@ -1,64 +0,0 @@
mod backends;
mod cache;
mod runner;
mod sandbox;
pub(crate) use backends::ExecutionMode;
pub(crate) use runner::ExecutionRequest;
pub(crate) use runner::Executor;
pub(crate) use runner::ExecutorConfig;
pub(crate) use runner::normalize_exec_result;
pub(crate) mod linkers {
use crate::codex::ExecCommandContext;
use crate::exec::ExecParams;
use crate::exec::StdoutStream;
use crate::executor::backends::ExecutionMode;
use crate::executor::runner::ExecutionRequest;
pub struct PreparedExec {
pub(crate) context: ExecCommandContext,
pub(crate) request: ExecutionRequest,
}
impl PreparedExec {
pub fn new(
context: ExecCommandContext,
params: ExecParams,
approval_command: Vec<String>,
mode: ExecutionMode,
stdout_stream: Option<StdoutStream>,
use_shell_profile: bool,
) -> Self {
let request = ExecutionRequest {
params,
approval_command,
mode,
stdout_stream,
use_shell_profile,
};
Self { context, request }
}
}
}
pub mod errors {
use crate::error::CodexErr;
use crate::function_tool::FunctionCallError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ExecError {
#[error(transparent)]
Function(#[from] FunctionCallError),
#[error(transparent)]
Codex(#[from] CodexErr),
}
impl ExecError {
pub(crate) fn rejection(msg: impl Into<String>) -> Self {
FunctionCallError::RespondToModel(msg.into()).into()
}
}
}

View File

@@ -1,387 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use std::time::Duration;
use super::backends::ExecutionMode;
use super::backends::backend_for_mode;
use super::cache::ApprovalCache;
use crate::codex::ExecCommandContext;
use crate::codex::Session;
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::error::get_error_message_ui;
use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::executor::errors::ExecError;
use crate::executor::sandbox::select_sandbox;
use crate::function_tool::FunctionCallError;
use crate::protocol::AskForApproval;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use crate::shell;
use codex_otel::otel_event_manager::ToolDecisionSource;
#[derive(Clone, Debug)]
pub(crate) struct ExecutorConfig {
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) sandbox_cwd: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
}
impl ExecutorConfig {
pub(crate) fn new(
sandbox_policy: SandboxPolicy,
sandbox_cwd: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> Self {
Self {
sandbox_policy,
sandbox_cwd,
codex_linux_sandbox_exe,
}
}
}
/// Coordinates sandbox selection, backend-specific preparation, and command
/// execution for tool calls requested by the model.
pub(crate) struct Executor {
approval_cache: ApprovalCache,
config: Arc<RwLock<ExecutorConfig>>,
}
impl Executor {
pub(crate) fn new(config: ExecutorConfig) -> Self {
Self {
approval_cache: ApprovalCache::default(),
config: Arc::new(RwLock::new(config)),
}
}
/// Updates the sandbox policy and working directory used for future
/// executions without recreating the executor.
pub(crate) fn update_environment(&self, sandbox_policy: SandboxPolicy, sandbox_cwd: PathBuf) {
if let Ok(mut cfg) = self.config.write() {
cfg.sandbox_policy = sandbox_policy;
cfg.sandbox_cwd = sandbox_cwd;
}
}
/// Runs a prepared execution request end-to-end: prepares parameters, decides on
/// sandbox placement (prompting the user when necessary), launches the command,
/// and lets the backend post-process the final output.
pub(crate) async fn run(
&self,
mut request: ExecutionRequest,
session: &Session,
approval_policy: AskForApproval,
context: &ExecCommandContext,
) -> Result<ExecToolCallOutput, ExecError> {
if matches!(request.mode, ExecutionMode::Shell) {
request.params =
maybe_translate_shell_command(request.params, session, request.use_shell_profile);
}
// Step 1: Normalise parameters via the selected backend.
let backend = backend_for_mode(&request.mode);
let stdout_stream = if backend.stream_stdout(&request.mode) {
request.stdout_stream.clone()
} else {
None
};
request.params = backend
.prepare(request.params, &request.mode)
.map_err(ExecError::from)?;
// Step 2: Snapshot sandbox configuration so it stays stable for this run.
let config = self
.config
.read()
.map_err(|_| ExecError::rejection("executor config poisoned"))?
.clone();
// Step 3: Decide sandbox placement, prompting for approval when needed.
let sandbox_decision = select_sandbox(
&request,
approval_policy,
self.approval_cache.snapshot(),
&config,
session,
&context.sub_id,
&context.call_id,
&context.otel_event_manager,
)
.await?;
if sandbox_decision.record_session_approval {
self.approval_cache.insert(request.approval_command.clone());
}
// Step 4: Launch the command within the chosen sandbox.
let first_attempt = self
.spawn(
request.params.clone(),
sandbox_decision.initial_sandbox,
&config,
stdout_stream.clone(),
)
.await;
// Step 5: Handle sandbox outcomes, optionally escalating to an unsandboxed retry.
match first_attempt {
Ok(output) => Ok(output),
Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => {
Err(CodexErr::Sandbox(SandboxErr::Timeout { output }).into())
}
Err(CodexErr::Sandbox(error)) => {
if sandbox_decision.escalate_on_failure {
self.retry_without_sandbox(
&request,
&config,
session,
context,
stdout_stream,
error,
)
.await
} else {
Err(ExecError::rejection(format!(
"failed in sandbox {:?} with execution error: {error:?}",
sandbox_decision.initial_sandbox
)))
}
}
Err(err) => Err(err.into()),
}
}
/// Fallback path invoked when a sandboxed run is denied so the user can
/// approve rerunning without isolation.
async fn retry_without_sandbox(
&self,
request: &ExecutionRequest,
config: &ExecutorConfig,
session: &Session,
context: &ExecCommandContext,
stdout_stream: Option<StdoutStream>,
sandbox_error: SandboxErr,
) -> Result<ExecToolCallOutput, ExecError> {
session
.notify_background_event(
&context.sub_id,
format!("Execution failed: {sandbox_error}"),
)
.await;
let decision = session
.request_command_approval(
context.sub_id.to_string(),
context.call_id.to_string(),
request.approval_command.clone(),
request.params.cwd.clone(),
Some("command failed; retry without sandbox?".to_string()),
)
.await;
context.otel_event_manager.tool_decision(
&context.tool_name,
&context.call_id,
decision,
ToolDecisionSource::User,
);
match decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
if matches!(decision, ReviewDecision::ApprovedForSession) {
self.approval_cache.insert(request.approval_command.clone());
}
session
.notify_background_event(&context.sub_id, "retrying command without sandbox")
.await;
let retry_output = self
.spawn(
request.params.clone(),
SandboxType::None,
config,
stdout_stream,
)
.await?;
Ok(retry_output)
}
ReviewDecision::Denied | ReviewDecision::Abort => {
Err(ExecError::rejection("exec command rejected by user"))
}
}
}
async fn spawn(
&self,
params: ExecParams,
sandbox: SandboxType,
config: &ExecutorConfig,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput, CodexErr> {
process_exec_tool_call(
params,
sandbox,
&config.sandbox_policy,
&config.sandbox_cwd,
&config.codex_linux_sandbox_exe,
stdout_stream,
)
.await
}
}
fn maybe_translate_shell_command(
params: ExecParams,
session: &Session,
use_shell_profile: bool,
) -> ExecParams {
let should_translate =
matches!(session.user_shell(), shell::Shell::PowerShell(_)) || use_shell_profile;
if should_translate
&& let Some(command) = session
.user_shell()
.format_default_shell_invocation(params.command.clone())
{
return ExecParams { command, ..params };
}
params
}
pub(crate) struct ExecutionRequest {
pub params: ExecParams,
pub approval_command: Vec<String>,
pub mode: ExecutionMode,
pub stdout_stream: Option<StdoutStream>,
pub use_shell_profile: bool,
}
pub(crate) struct NormalizedExecOutput<'a> {
borrowed: Option<&'a ExecToolCallOutput>,
synthetic: Option<ExecToolCallOutput>,
}
impl<'a> NormalizedExecOutput<'a> {
pub(crate) fn event_output(&'a self) -> &'a ExecToolCallOutput {
match (self.borrowed, self.synthetic.as_ref()) {
(Some(output), _) => output,
(None, Some(output)) => output,
(None, None) => unreachable!("normalized exec output missing data"),
}
}
}
/// Converts a raw execution result into a uniform view that always exposes an
/// [`ExecToolCallOutput`], synthesizing error output when the command fails
/// before producing a response.
pub(crate) fn normalize_exec_result(
result: &Result<ExecToolCallOutput, ExecError>,
) -> NormalizedExecOutput<'_> {
match result {
Ok(output) => NormalizedExecOutput {
borrowed: Some(output),
synthetic: None,
},
Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => {
NormalizedExecOutput {
borrowed: Some(output.as_ref()),
synthetic: None,
}
}
Err(err) => {
let message = match err {
ExecError::Function(FunctionCallError::RespondToModel(msg)) => msg.clone(),
ExecError::Codex(e) => get_error_message_ui(e),
};
let synthetic = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(message.clone()),
aggregated_output: StreamOutput::new(message),
duration: Duration::default(),
timed_out: false,
};
NormalizedExecOutput {
borrowed: None,
synthetic: Some(synthetic),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::CodexErr;
use crate::error::EnvVarError;
use crate::error::SandboxErr;
use crate::exec::StreamOutput;
use pretty_assertions::assert_eq;
fn make_output(text: &str) -> ExecToolCallOutput {
ExecToolCallOutput {
exit_code: 1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(text.to_string()),
duration: Duration::from_millis(123),
timed_out: false,
}
}
#[test]
fn normalize_success_borrows() {
let out = make_output("ok");
let result: Result<ExecToolCallOutput, ExecError> = Ok(out);
let normalized = normalize_exec_result(&result);
assert_eq!(normalized.event_output().aggregated_output.text, "ok");
}
#[test]
fn normalize_timeout_borrows_embedded_output() {
let out = make_output("timed out payload");
let err = CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(out),
});
let result: Result<ExecToolCallOutput, ExecError> = Err(ExecError::Codex(err));
let normalized = normalize_exec_result(&result);
assert_eq!(
normalized.event_output().aggregated_output.text,
"timed out payload"
);
}
#[test]
fn normalize_function_error_synthesizes_payload() {
let err = FunctionCallError::RespondToModel("boom".to_string());
let result: Result<ExecToolCallOutput, ExecError> = Err(ExecError::Function(err));
let normalized = normalize_exec_result(&result);
assert_eq!(normalized.event_output().aggregated_output.text, "boom");
}
#[test]
fn normalize_codex_error_synthesizes_user_message() {
// Use a simple EnvVar error which formats to a clear message
let e = CodexErr::EnvVar(EnvVarError {
var: "FOO".to_string(),
instructions: Some("set it".to_string()),
});
let result: Result<ExecToolCallOutput, ExecError> = Err(ExecError::Codex(e));
let normalized = normalize_exec_result(&result);
assert!(
normalized
.event_output()
.aggregated_output
.text
.contains("Missing environment variable: `FOO`"),
"expected synthesized user-friendly message"
);
}
}

View File

@@ -1,405 +0,0 @@
use crate::apply_patch::ApplyPatchExec;
use crate::codex::Session;
use crate::exec::SandboxType;
use crate::executor::ExecutionMode;
use crate::executor::ExecutionRequest;
use crate::executor::ExecutorConfig;
use crate::executor::errors::ExecError;
use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_patch_safety;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_otel::otel_event_manager::ToolDecisionSource;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use std::collections::HashSet;
/// Sandbox placement options selected for an execution run, including whether
/// to escalate after failures and whether approvals should persist.
pub(crate) struct SandboxDecision {
pub(crate) initial_sandbox: SandboxType,
pub(crate) escalate_on_failure: bool,
pub(crate) record_session_approval: bool,
}
impl SandboxDecision {
fn auto(sandbox: SandboxType, escalate_on_failure: bool) -> Self {
Self {
initial_sandbox: sandbox,
escalate_on_failure,
record_session_approval: false,
}
}
fn user_override(record_session_approval: bool) -> Self {
Self {
initial_sandbox: SandboxType::None,
escalate_on_failure: false,
record_session_approval,
}
}
}
fn should_escalate_on_failure(approval: AskForApproval, sandbox: SandboxType) -> bool {
matches!(
(approval, sandbox),
(
AskForApproval::UnlessTrusted | AskForApproval::OnFailure,
SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp
)
)
}
/// Determines how a command should be sandboxed, prompting the user when
/// policy requires explicit approval.
#[allow(clippy::too_many_arguments)]
pub async fn select_sandbox(
request: &ExecutionRequest,
approval_policy: AskForApproval,
approval_cache: HashSet<Vec<String>>,
config: &ExecutorConfig,
session: &Session,
sub_id: &str,
call_id: &str,
otel_event_manager: &OtelEventManager,
) -> Result<SandboxDecision, ExecError> {
match &request.mode {
ExecutionMode::Shell => {
select_shell_sandbox(
request,
approval_policy,
approval_cache,
config,
session,
sub_id,
call_id,
otel_event_manager,
)
.await
}
ExecutionMode::ApplyPatch(exec) => {
select_apply_patch_sandbox(exec, approval_policy, config)
}
}
}
#[allow(clippy::too_many_arguments)]
async fn select_shell_sandbox(
request: &ExecutionRequest,
approval_policy: AskForApproval,
approved_snapshot: HashSet<Vec<String>>,
config: &ExecutorConfig,
session: &Session,
sub_id: &str,
call_id: &str,
otel_event_manager: &OtelEventManager,
) -> Result<SandboxDecision, ExecError> {
let command_for_safety = if request.approval_command.is_empty() {
request.params.command.clone()
} else {
request.approval_command.clone()
};
let safety = assess_command_safety(
&command_for_safety,
approval_policy,
&config.sandbox_policy,
&approved_snapshot,
request.params.with_escalated_permissions.unwrap_or(false),
);
match safety {
SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved,
} => {
let mut decision = SandboxDecision::auto(
sandbox_type,
should_escalate_on_failure(approval_policy, sandbox_type),
);
if user_explicitly_approved {
decision.record_session_approval = true;
}
let (decision_for_event, source) = if user_explicitly_approved {
(ReviewDecision::ApprovedForSession, ToolDecisionSource::User)
} else {
(ReviewDecision::Approved, ToolDecisionSource::Config)
};
otel_event_manager.tool_decision("local_shell", call_id, decision_for_event, source);
Ok(decision)
}
SafetyCheck::AskUser => {
let decision = session
.request_command_approval(
sub_id.to_string(),
call_id.to_string(),
request.approval_command.clone(),
request.params.cwd.clone(),
request.params.justification.clone(),
)
.await;
otel_event_manager.tool_decision(
"local_shell",
call_id,
decision,
ToolDecisionSource::User,
);
match decision {
ReviewDecision::Approved => Ok(SandboxDecision::user_override(false)),
ReviewDecision::ApprovedForSession => Ok(SandboxDecision::user_override(true)),
ReviewDecision::Denied | ReviewDecision::Abort => {
Err(ExecError::rejection("exec command rejected by user"))
}
}
}
SafetyCheck::Reject { reason } => Err(ExecError::rejection(format!(
"exec command rejected: {reason}"
))),
}
}
fn select_apply_patch_sandbox(
exec: &ApplyPatchExec,
approval_policy: AskForApproval,
config: &ExecutorConfig,
) -> Result<SandboxDecision, ExecError> {
if exec.user_explicitly_approved_this_action {
return Ok(SandboxDecision::user_override(false));
}
match assess_patch_safety(
&exec.action,
approval_policy,
&config.sandbox_policy,
&config.sandbox_cwd,
) {
SafetyCheck::AutoApprove { sandbox_type, .. } => Ok(SandboxDecision::auto(
sandbox_type,
should_escalate_on_failure(approval_policy, sandbox_type),
)),
SafetyCheck::AskUser => Err(ExecError::rejection(
"patch requires approval but none was recorded",
)),
SafetyCheck::Reject { reason } => {
Err(ExecError::rejection(format!("patch rejected: {reason}")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::exec::ExecParams;
use crate::function_tool::FunctionCallError;
use crate::protocol::SandboxPolicy;
use codex_apply_patch::ApplyPatchAction;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn select_apply_patch_user_override_when_explicit() {
let (session, ctx) = make_session_and_context();
let tmp = tempfile::tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let exec = ApplyPatchExec {
action,
user_explicitly_approved_this_action: true,
};
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
cwd: std::env::temp_dir(),
timeout_ms: None,
env: std::collections::HashMap::new(),
with_escalated_permissions: None,
justification: None,
},
approval_command: vec!["apply_patch".into()],
mode: ExecutionMode::ApplyPatch(exec),
stdout_stream: None,
use_shell_profile: false,
};
let otel_event_manager = ctx.client.get_otel_event_manager();
let decision = select_sandbox(
&request,
AskForApproval::OnRequest,
Default::default(),
&cfg,
&session,
"sub",
"call",
&otel_event_manager,
)
.await
.expect("ok");
// Explicit user override runs without sandbox
assert_eq!(decision.initial_sandbox, SandboxType::None);
assert_eq!(decision.escalate_on_failure, false);
}
#[tokio::test]
async fn select_apply_patch_autoapprove_in_danger() {
let (session, ctx) = make_session_and_context();
let tmp = tempfile::tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let exec = ApplyPatchExec {
action,
user_explicitly_approved_this_action: false,
};
let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
cwd: std::env::temp_dir(),
timeout_ms: None,
env: std::collections::HashMap::new(),
with_escalated_permissions: None,
justification: None,
},
approval_command: vec!["apply_patch".into()],
mode: ExecutionMode::ApplyPatch(exec),
stdout_stream: None,
use_shell_profile: false,
};
let otel_event_manager = ctx.client.get_otel_event_manager();
let decision = select_sandbox(
&request,
AskForApproval::OnRequest,
Default::default(),
&cfg,
&session,
"sub",
"call",
&otel_event_manager,
)
.await
.expect("ok");
// On platforms with a sandbox, DangerFullAccess still prefers it
let expected = crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None);
assert_eq!(decision.initial_sandbox, expected);
assert_eq!(decision.escalate_on_failure, false);
}
#[tokio::test]
async fn select_apply_patch_requires_approval_on_unless_trusted() {
let (session, ctx) = make_session_and_context();
let tempdir = tempfile::tempdir().expect("tmpdir");
let p = tempdir.path().join("a.txt");
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let exec = ApplyPatchExec {
action,
user_explicitly_approved_this_action: false,
};
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
cwd: std::env::temp_dir(),
timeout_ms: None,
env: std::collections::HashMap::new(),
with_escalated_permissions: None,
justification: None,
},
approval_command: vec!["apply_patch".into()],
mode: ExecutionMode::ApplyPatch(exec),
stdout_stream: None,
use_shell_profile: false,
};
let otel_event_manager = ctx.client.get_otel_event_manager();
let result = select_sandbox(
&request,
AskForApproval::UnlessTrusted,
Default::default(),
&cfg,
&session,
"sub",
"call",
&otel_event_manager,
)
.await;
match result {
Ok(_) => panic!("expected error"),
Err(ExecError::Function(FunctionCallError::RespondToModel(msg))) => {
assert!(msg.contains("requires approval"))
}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn select_shell_autoapprove_in_danger_mode() {
let (session, ctx) = make_session_and_context();
let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["some-unknown".into()],
cwd: std::env::temp_dir(),
timeout_ms: None,
env: std::collections::HashMap::new(),
with_escalated_permissions: None,
justification: None,
},
approval_command: vec!["some-unknown".into()],
mode: ExecutionMode::Shell,
stdout_stream: None,
use_shell_profile: false,
};
let otel_event_manager = ctx.client.get_otel_event_manager();
let decision = select_sandbox(
&request,
AskForApproval::OnRequest,
Default::default(),
&cfg,
&session,
"sub",
"call",
&otel_event_manager,
)
.await
.expect("ok");
assert_eq!(decision.initial_sandbox, SandboxType::None);
assert_eq!(decision.escalate_on_failure, false);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn select_shell_escalates_on_failure_with_platform_sandbox() {
let (session, ctx) = make_session_and_context();
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let request = ExecutionRequest {
params: ExecParams {
// Unknown command => untrusted but not flagged dangerous
command: vec!["some-unknown".into()],
cwd: std::env::temp_dir(),
timeout_ms: None,
env: std::collections::HashMap::new(),
with_escalated_permissions: None,
justification: None,
},
approval_command: vec!["some-unknown".into()],
mode: ExecutionMode::Shell,
stdout_stream: None,
use_shell_profile: false,
};
let otel_event_manager = ctx.client.get_otel_event_manager();
let decision = select_sandbox(
&request,
AskForApproval::OnFailure,
Default::default(),
&cfg,
&session,
"sub",
"call",
&otel_event_manager,
)
.await
.expect("ok");
// On macOS/Linux we should have a platform sandbox and escalate on failure
assert_ne!(decision.initial_sandbox, SandboxType::None);
assert_eq!(decision.escalate_on_failure, true);
}
}

View File

@@ -27,7 +27,6 @@ pub mod error;
pub mod exec;
mod exec_command;
pub mod exec_env;
pub mod executor;
mod flags;
pub mod git_info;
pub mod landlock;
@@ -68,6 +67,7 @@ pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;

View File

@@ -122,6 +122,26 @@ impl ToolsConfig {
}
}
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
impl From<bool> for AdditionalProperties {
fn from(b: bool) -> Self {
Self::Boolean(b)
}
}
impl From<JsonSchema> for AdditionalProperties {
fn from(s: JsonSchema) -> Self {
Self::Schema(Box::new(s))
}
}
/// Generic JSONSchema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -154,7 +174,7 @@ pub(crate) enum JsonSchema {
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
additional_properties: Option<bool>,
additional_properties: Option<AdditionalProperties>,
},
}
@@ -200,7 +220,7 @@ fn create_unified_exec_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -247,7 +267,7 @@ fn create_shell_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -271,7 +291,7 @@ fn create_view_image_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -708,7 +728,130 @@ mod tests {
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: None,
},
description: "Do something cool".to_string(),
strict: false,
})
);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
include_view_image_tool: true,
experimental_unified_exec_tool: true,
});
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
},
"required": [
"string_property",
"number_property",
],
"additionalProperties": {
"type": "object",
"properties": {
"addtl_prop": { "type": "string" },
},
"required": [
"addtl_prop",
],
"additionalProperties": false,
},
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
)])),
);
assert_eq_tool_names(
&tools,
&[
"unified_exec",
"web_search",
"view_image",
"test_server/do_something_cool",
],
);
assert_eq!(
tools[3],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
),
]),
required: Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(
JsonSchema::Object {
properties: BTreeMap::from([(
"addtl_prop".to_string(),
JsonSchema::String { description: None }
),]),
required: Some(vec!["addtl_prop".to_string(),]),
additional_properties: Some(false.into()),
}
.into()
),
},
),
]),

View File

@@ -32,7 +32,7 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
items: Box::new(JsonSchema::Object {
properties: plan_item_props,
required: Some(vec!["step".to_string(), "status".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
}),
};
@@ -54,7 +54,7 @@ At most one step can be in_progress at a time.
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
});

View File

@@ -1,6 +1,7 @@
//! Project-level documentation discovery.
//!
//! Project-level documentation can be stored in files named `AGENTS.md`.
//! Project-level documentation is primarily stored in files named `AGENTS.md`.
//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`.
//! We include the concatenation of all files found along the path from the
//! repository root to the current working directory as follows:
//!
@@ -13,12 +14,13 @@
//! 3. We do **not** walk past the Git root.
use crate::config::Config;
use dunce::canonicalize as normalize_path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use tracing::error;
/// Currently, we only match the filename `AGENTS.md` exactly.
const CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"];
/// Default filename scanned for project-level docs.
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
/// When both `Config::instructions` and the project doc are present, they will
/// be concatenated with the following separator.
@@ -108,7 +110,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
/// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
let mut dir = config.cwd.clone();
if let Ok(canon) = dir.canonicalize() {
if let Ok(canon) = normalize_path(&dir) {
dir = canon;
}
@@ -152,8 +154,9 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
};
let mut found: Vec<PathBuf> = Vec::new();
let candidate_filenames = candidate_filenames(config);
for d in search_dirs {
for name in CANDIDATE_FILENAMES {
for name in &candidate_filenames {
let candidate = d.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(md) => {
@@ -173,6 +176,22 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
Ok(found)
}
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> =
Vec::with_capacity(1 + config.project_doc_fallback_filenames.len());
names.push(DEFAULT_PROJECT_DOC_FILENAME);
for candidate in &config.project_doc_fallback_filenames {
let candidate = candidate.as_str();
if candidate.is_empty() {
continue;
}
if !names.contains(&candidate) {
names.push(candidate);
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
@@ -202,6 +221,20 @@ mod tests {
config
}
fn make_config_with_fallback(
root: &TempDir,
limit: usize,
instructions: Option<&str>,
fallbacks: &[&str],
) -> Config {
let mut config = make_config(root, limit, instructions);
config.project_doc_fallback_filenames = fallbacks
.iter()
.map(std::string::ToString::to_string)
.collect();
config
}
/// AGENTS.md missing should yield `None`.
#[tokio::test]
async fn no_doc_file_returns_none() {
@@ -347,4 +380,45 @@ mod tests {
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
}
/// When AGENTS.md is absent but a configured fallback exists, the fallback is used.
#[tokio::test]
async fn uses_configured_fallback_when_agents_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]);
let res = get_user_instructions(&cfg)
.await
.expect("fallback doc expected");
assert_eq!(res, "example instructions");
}
/// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present.
#[tokio::test]
async fn agents_md_preferred_over_fallbacks() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap();
fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]);
let res = get_user_instructions(&cfg)
.await
.expect("AGENTS.md should win");
assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
assert_eq!(discovery.len(), 1);
assert!(
discovery[0]
.file_name()
.unwrap()
.to_string_lossy()
.eq(DEFAULT_PROJECT_DOC_FILENAME)
);
}
}

View File

@@ -17,6 +17,7 @@ use super::SESSIONS_SUBDIR;
use crate::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource;
/// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)]
@@ -52,6 +53,7 @@ struct HeadTailSummary {
tail: Vec<serde_json::Value>,
saw_session_meta: bool,
saw_user_event: bool,
source: Option<SessionSource>,
created_at: Option<String>,
updated_at: Option<String>,
}
@@ -106,6 +108,7 @@ pub(crate) async fn get_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
@@ -121,7 +124,8 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned();
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?;
let result =
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
Ok(result)
}
@@ -140,6 +144,7 @@ async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -196,6 +201,13 @@ async fn traverse_directories_for_paths(
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
@@ -341,6 +353,7 @@ async fn read_head_and_tail(
match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
summary.source = Some(session_meta_line.meta.source);
summary.created_at = summary
.created_at
.clone()

View File

@@ -1,7 +1,11 @@
//! Rollout module: persistence and discovery of session rollout files.
use codex_protocol::protocol::SessionSource;
pub const SESSIONS_SUBDIR: &str = "sessions";
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
&[SessionSource::Cli, SessionSource::VSCode];
pub mod list;
pub(crate) mod policy;

View File

@@ -70,6 +70,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::ConversationPath(_) => false,
}
}

View File

@@ -32,6 +32,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
@@ -53,6 +54,7 @@ pub enum RolloutRecorderParams {
Create {
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
},
Resume {
path: PathBuf,
@@ -71,10 +73,15 @@ enum RolloutCmd {
}
impl RolloutRecorderParams {
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
pub fn new(
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
) -> Self {
Self::Create {
conversation_id,
instructions,
source,
}
}
@@ -89,8 +96,9 @@ impl RolloutRecorder {
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor).await
get_conversations(codex_home, page_size, cursor, allowed_sources).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -101,6 +109,7 @@ impl RolloutRecorder {
RolloutRecorderParams::Create {
conversation_id,
instructions,
source,
} => {
let LogFileInfo {
file,
@@ -127,6 +136,7 @@ impl RolloutRecorder {
originator: originator().value.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
}),
)
}

View File

@@ -12,6 +12,7 @@ use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::list::ConversationItem;
use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor;
@@ -28,13 +29,17 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::UserMessageEvent;
const NO_SOURCE_FILTER: &[SessionSource] = &[];
fn write_session_file(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -52,17 +57,23 @@ fn write_session_file(
let file_path = dir.join(filename);
let mut file = File::create(file_path)?;
let mut payload = serde_json::json!({
"id": uuid,
"timestamp": ts_str,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
});
if let Some(source) = source {
payload["source"] = serde_json::to_value(source).unwrap();
}
let meta = serde_json::json!({
"timestamp": ts_str,
"type": "session_meta",
"payload": {
"id": uuid,
"timestamp": ts_str,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
}
"payload": payload,
});
writeln!(file, "{meta}")?;
@@ -99,11 +110,34 @@ async fn test_list_conversations_latest_first() {
let u3 = Uuid::from_u128(3);
// Create three sessions across three days
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap();
write_session_file(
home,
"2025-01-01T12-00-00",
u1,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-02T12-00-00",
u2,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-03T12-00-00",
u3,
3,
Some(SessionSource::VSCode),
)
.unwrap();
let page = get_conversations(home, 10, None).await.unwrap();
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
// Build expected objects
let p1 = home
@@ -131,7 +165,8 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -139,7 +174,8 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let head_1 = vec![serde_json::json!({
"id": u1,
@@ -147,7 +183,8 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let expected_cursor: Cursor =
@@ -198,13 +235,50 @@ async fn test_pagination_cursor() {
let u5 = Uuid::from_u128(55);
// Oldest to newest
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap();
write_session_file(
home,
"2025-03-01T09-00-00",
u1,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-02T09-00-00",
u2,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-03T09-00-00",
u3,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-04T09-00-00",
u4,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-05T09-00-00",
u5,
1,
Some(SessionSource::VSCode),
)
.unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p5 = home
.join("sessions")
.join("2025")
@@ -223,7 +297,8 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let head_4 = vec![serde_json::json!({
"id": u4,
@@ -231,7 +306,8 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let expected_cursor1: Cursor =
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
@@ -258,9 +334,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p3 = home
.join("sessions")
.join("2025")
@@ -279,7 +360,8 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -287,7 +369,8 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let expected_cursor2: Cursor =
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
@@ -314,9 +397,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page2, expected_page2);
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref())
.await
.unwrap();
let page3 = get_conversations(
home,
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -329,7 +417,8 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let expected_cursor3: Cursor =
serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap();
@@ -355,9 +444,11 @@ async fn test_get_conversation_contents() {
let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2).unwrap();
write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap();
let page = get_conversations(home, 1, None).await.unwrap();
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap();
@@ -375,7 +466,8 @@ async fn test_get_conversation_contents() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})];
let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap();
let expected_page = ConversationsPage {
@@ -393,7 +485,19 @@ async fn test_get_conversation_contents() {
assert_eq!(page, expected_page);
// Entire file contents equality
let meta = serde_json::json!({"timestamp": ts, "type": "session_meta", "payload": {"id": uuid, "timestamp": ts, "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version"}});
let meta = serde_json::json!({
"timestamp": ts,
"type": "session_meta",
"payload": {
"id": uuid,
"timestamp": ts,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
}
});
let user_event = serde_json::json!({
"timestamp": ts,
"type": "event_msg",
@@ -428,6 +532,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -460,7 +565,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let item = page.items.first().expect("conversation item");
let tail_len = item.tail.len();
assert_eq!(tail_len, 10usize.min(total_messages));
@@ -511,6 +616,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -542,7 +648,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let tail = &page.items.first().expect("conversation item").tail;
assert_eq!(tail.len(), 3);
@@ -595,6 +701,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -640,7 +747,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?;
drop(file);
let page = get_conversations(home, 1, None).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let tail = &page.items.first().expect("conversation item").tail;
let expected: Vec<serde_json::Value> = (0..4)
@@ -678,11 +785,13 @@ async fn test_stable_ordering_same_second_pagination() {
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0).unwrap();
write_session_file(home, ts, u2, 0).unwrap();
write_session_file(home, ts, u3, 0).unwrap();
write_session_file(home, ts, u1, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p3 = home
.join("sessions")
@@ -703,7 +812,8 @@ async fn test_stable_ordering_same_second_pagination() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"source": "vscode",
})]
};
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
@@ -730,9 +840,14 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -754,3 +869,59 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page2, expected_page2);
}
#[tokio::test]
async fn test_source_filter_excludes_non_matching_sessions() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let interactive_id = Uuid::from_u128(42);
let non_interactive_id = Uuid::from_u128(77);
write_session_file(
home,
"2025-08-02T10-00-00",
interactive_id,
2,
Some(SessionSource::Cli),
)
.unwrap();
write_session_file(
home,
"2025-08-01T10-00-00",
non_interactive_id,
2,
Some(SessionSource::Exec),
)
.unwrap();
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let paths: Vec<_> = interactive_only
.items
.iter()
.map(|item| item.path.as_path())
.collect();
assert_eq!(paths.len(), 1);
assert!(paths.iter().all(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
.items
.into_iter()
.map(|item| item.path)
.collect();
assert_eq!(all_paths.len(), 2);
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl")
}));
}

View File

@@ -125,10 +125,9 @@ pub fn assess_command_safety(
// the session _because_ they know it needs to run outside a sandbox.
if is_known_safe_command(command) || approved.contains(command) {
let user_explicitly_approved = approved.contains(command);
return SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved,
user_explicitly_approved: false,
};
}
@@ -381,7 +380,7 @@ mod tests {
safety_check,
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved: true,
user_explicitly_approved: false,
}
);
}

View File

@@ -1,9 +1,9 @@
use crate::RolloutRecorder;
use crate::exec_command::ExecSessionManager;
use crate::executor::Executor;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_notification::UserNotifier;
use std::path::PathBuf;
use tokio::sync::Mutex;
pub(crate) struct SessionServices {
@@ -12,7 +12,7 @@ pub(crate) struct SessionServices {
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
pub(crate) notifier: UserNotifier,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) user_shell: crate::shell::Shell,
pub(crate) show_raw_agent_reasoning: bool,
pub(crate) executor: Executor,
}

View File

@@ -1,5 +1,7 @@
//! Session-wide mutable state.
use std::collections::HashSet;
use codex_protocol::models::ResponseItem;
use crate::conversation_history::ConversationHistory;
@@ -10,6 +12,7 @@ use crate::protocol::TokenUsageInfo;
/// Persistent, session-scoped state previously stored directly on `Session`.
#[derive(Default)]
pub(crate) struct SessionState {
pub(crate) approved_commands: HashSet<Vec<String>>,
pub(crate) history: ConversationHistory,
pub(crate) token_info: Option<TokenUsageInfo>,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
@@ -41,6 +44,15 @@ impl SessionState {
self.history.replace(items);
}
// Approved command helpers
pub(crate) fn add_approved_command(&mut self, cmd: Vec<String>) {
self.approved_commands.insert(cmd);
}
pub(crate) fn approved_commands_ref(&self) -> &HashSet<Vec<String>> {
&self.approved_commands
}
// Token/rate limit helpers
pub(crate) fn update_token_info_from_usage(
&mut self,

View File

@@ -116,7 +116,7 @@ It is important to remember:
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}

View File

@@ -1,4 +1,5 @@
#![allow(clippy::expect_used)]
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use std::path::Path;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -14,7 +15,7 @@ impl TestCodexExecBuilder {
.expect("should find binary for codex-exec");
cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path())
.env("OPENAI_API_KEY", "dummy");
.env(CODEX_API_KEY_ENV_VAR, "dummy");
cmd
}
pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command {

View File

@@ -76,7 +76,7 @@ async fn chat_mode_stream_cli() {
server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None)
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[])
.await
.expect("list conversations");
assert!(

View File

@@ -17,6 +17,7 @@ use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::ConversationId;
use codex_protocol::models::ReasoningItemReasoningSummary;
@@ -538,7 +539,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec);
let NewConversation {
conversation: codex,
..

View File

@@ -169,12 +169,6 @@ async fn python_getpwuid_works_under_seatbelt() {
return;
}
// For local dev.
if which::which("python3").is_err() {
eprintln!("python3 not found in PATH, skipping test.");
return;
}
// ReadOnly is sufficient here since we are only exercising user lookup.
let policy = SandboxPolicy::ReadOnly;
let command_cwd = std::env::current_dir().expect("getcwd");

View File

@@ -16,7 +16,6 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = [
@@ -27,6 +26,7 @@ codex-common = { workspace = true, features = [
codex-core = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -40,7 +40,6 @@ tokio = { workspace = true, features = [
] }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
opentelemetry-appender-tracing = { workspace = true }
ts-rs = { workspace = true, features = [
"uuid-impl",
"serde-json-impl",
@@ -52,10 +51,10 @@ ts-rs = { workspace = true, features = [
assert_cmd = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
wiremock = { workspace = true }
mcp-types = { workspace = true }

View File

@@ -21,6 +21,8 @@ pub(crate) trait EventProcessor {
/// Handle a single event emitted by the agent.
fn process_event(&mut self, event: Event) -> CodexStatus;
fn print_final_output(&mut self) {}
}
pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file: &Path) {

View File

@@ -2,10 +2,7 @@ use codex_common::elapsed::format_duration;
use codex_common::elapsed::format_elapsed;
use codex_core::config::Config;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningRawContentDeltaEvent;
use codex_core::protocol::AgentReasoningRawContentEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::ErrorEvent;
@@ -31,7 +28,6 @@ use owo_colors::OwoColorize;
use owo_colors::Style;
use shlex::try_join;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;
@@ -44,7 +40,6 @@ use codex_common::create_config_summary_entries;
/// a limit so they can see the full transcript.
const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
pub(crate) struct EventProcessorWithHumanOutput {
call_id_to_command: HashMap<String, ExecCommandBegin>,
call_id_to_patch: HashMap<String, PatchApplyBegin>,
// To ensure that --color=never is respected, ANSI escapes _must_ be added
@@ -62,10 +57,8 @@ pub(crate) struct EventProcessorWithHumanOutput {
/// Whether to include `AgentReasoning` events in the output.
show_agent_reasoning: bool,
show_raw_agent_reasoning: bool,
answer_started: bool,
reasoning_started: bool,
raw_reasoning_started: bool,
last_message_path: Option<PathBuf>,
last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>,
}
impl EventProcessorWithHumanOutput {
@@ -74,12 +67,10 @@ impl EventProcessorWithHumanOutput {
config: &Config,
last_message_path: Option<PathBuf>,
) -> Self {
let call_id_to_command = HashMap::new();
let call_id_to_patch = HashMap::new();
if with_ansi {
Self {
call_id_to_command,
call_id_to_patch,
bold: Style::new().bold(),
italic: Style::new().italic(),
@@ -90,14 +81,11 @@ impl EventProcessorWithHumanOutput {
cyan: Style::new().cyan(),
show_agent_reasoning: !config.hide_agent_reasoning,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
answer_started: false,
reasoning_started: false,
raw_reasoning_started: false,
last_message_path,
last_total_token_usage: None,
}
} else {
Self {
call_id_to_command,
call_id_to_patch,
bold: Style::new(),
italic: Style::new(),
@@ -108,19 +96,13 @@ impl EventProcessorWithHumanOutput {
cyan: Style::new(),
show_agent_reasoning: !config.hide_agent_reasoning,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
answer_started: false,
reasoning_started: false,
raw_reasoning_started: false,
last_message_path,
last_total_token_usage: None,
}
}
}
}
struct ExecCommandBegin {
command: Vec<String>,
}
struct PatchApplyBegin {
start_time: Instant,
auto_approved: bool,
@@ -130,9 +112,6 @@ struct PatchApplyBegin {
#[macro_export]
macro_rules! ts_println {
($self:ident, $($arg:tt)*) => {{
let now = chrono::Utc::now();
let formatted = now.format("[%Y-%m-%dT%H:%M:%S]");
print!("{} ", formatted.style($self.dimmed));
println!($($arg)*);
}};
}
@@ -141,7 +120,12 @@ impl EventProcessor for EventProcessorWithHumanOutput {
/// Print a concise summary of the effective configuration that will be used
/// for the session. This mirrors the information shown in the TUI welcome
/// screen.
fn print_config_summary(&mut self, config: &Config, prompt: &str, _: &SessionConfiguredEvent) {
fn print_config_summary(
&mut self,
config: &Config,
prompt: &str,
session_configured_event: &SessionConfiguredEvent,
) {
const VERSION: &str = env!("CARGO_PKG_VERSION");
ts_println!(
self,
@@ -149,7 +133,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
VERSION
);
let entries = create_config_summary_entries(config);
let mut entries = create_config_summary_entries(config);
entries.push((
"session id",
session_configured_event.session_id.to_string(),
));
for (key, value) in entries {
println!("{} {}", format!("{key}:").style(self.bold), value);
@@ -160,12 +148,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// Echo the prompt that will be sent to the agent so it is visible in the
// transcript/logs before any events come in. Note the prompt may have been
// read from stdin, so it may not be visible in the terminal otherwise.
ts_println!(
self,
"{}\n{}",
"User instructions:".style(self.bold).style(self.cyan),
prompt
);
ts_println!(self, "{}\n{}", "user".style(self.cyan), prompt);
}
fn process_event(&mut self, event: Event) -> CodexStatus {
@@ -191,126 +174,49 @@ impl EventProcessor for EventProcessorWithHumanOutput {
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(ev) => {
if let Some(usage_info) = ev.info {
ts_println!(
self,
"tokens used: {}",
format_with_separators(usage_info.total_token_usage.blended_total())
);
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta));
self.answer_started = true;
}
print!("{delta}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
if !self.show_agent_reasoning {
return CodexStatus::Running;
}
if !self.reasoning_started {
ts_println!(
self,
"{}\n",
"thinking".style(self.italic).style(self.magenta),
);
self.reasoning_started = true;
}
print!("{delta}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
self.last_total_token_usage = ev.info;
}
EventMsg::AgentReasoningSectionBreak(_) => {
if !self.show_agent_reasoning {
return CodexStatus::Running;
}
println!();
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if !self.show_raw_agent_reasoning {
return CodexStatus::Running;
}
if !self.raw_reasoning_started {
print!("{text}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
} else {
println!();
self.raw_reasoning_started = false;
}
}
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
}) => {
if !self.show_raw_agent_reasoning {
return CodexStatus::Running;
}
if !self.raw_reasoning_started {
self.raw_reasoning_started = true;
}
print!("{delta}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// if answer_started is false, this means we haven't received any
// delta. Thus, we need to print the message as a new answer.
if !self.answer_started {
if self.show_raw_agent_reasoning {
ts_println!(
self,
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
message,
"thinking".style(self.italic).style(self.magenta),
text,
);
} else {
println!();
self.answer_started = false;
}
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id,
command,
cwd,
parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id,
ExecCommandBegin {
command: command.clone(),
},
);
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
ts_println!(
self,
"{} {} in {}",
"exec".style(self.magenta),
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
message,
);
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
print!(
"{}\n{} in {}",
"exec".style(self.italic).style(self.magenta),
escape_command(&command).style(self.bold),
cwd.to_string_lossy(),
);
}
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
aggregated_output,
duration,
exit_code,
..
}) => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
{
(
format!(" in {}", format_duration(duration)),
format!("{}", escape_command(&command).style(self.bold)),
)
} else {
("".to_string(), format!("exec('{call_id}')"))
};
let duration = format!(" in {}", format_duration(duration));
let truncated_output = aggregated_output
.lines()
@@ -319,11 +225,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
.join("\n");
match exit_code {
0 => {
let title = format!("{call} succeeded{duration}:");
let title = format!(" succeeded{duration}:");
ts_println!(self, "{}", title.style(self.green));
}
_ => {
let title = format!("{call} exited {exit_code}{duration}:");
let title = format!(" exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(self.red));
}
}
@@ -391,9 +297,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
ts_println!(
self,
"{} auto_approved={}:",
"apply_patch".style(self.magenta),
auto_approved,
"{}",
"file update".style(self.magenta).style(self.italic),
);
// Pretty-print the patch summary with colored diff markers so
@@ -492,7 +397,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
ts_println!(self, "{}", "turn diff:".style(self.magenta));
ts_println!(
self,
"{}",
"file update:".style(self.magenta).style(self.italic)
);
println!("{unified_diff}");
}
EventMsg::ExecApprovalRequest(_) => {
@@ -503,17 +412,12 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::AgentReasoning(agent_reasoning_event) => {
if self.show_agent_reasoning {
if !self.reasoning_started {
ts_println!(
self,
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
agent_reasoning_event.text,
);
} else {
println!();
self.reasoning_started = false;
}
ts_println!(
self,
"{}\n{}",
"thinking".style(self.italic).style(self.magenta),
agent_reasoning_event.text,
);
}
}
EventMsg::SessionConfigured(session_configured_event) => {
@@ -580,6 +484,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ListCustomPromptsResponse(_) => {
// Currently ignored in exec output.
}
EventMsg::ViewImageToolCall(view) => {
ts_println!(
self,
"{} {}",
"viewed image".style(self.magenta),
view.path.display()
);
}
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted");
@@ -596,9 +508,23 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::UserMessage(_) => {}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
EventMsg::AgentMessageDelta(_) => {}
EventMsg::AgentReasoningDelta(_) => {}
EventMsg::AgentReasoningRawContentDelta(_) => {}
}
CodexStatus::Running
}
fn print_final_output(&mut self) {
if let Some(usage_info) = &self.last_total_token_usage {
ts_println!(
self,
"{}\n{}",
"tokens used".style(self.magenta).style(self.italic),
format_with_separators(usage_info.total_token_usage.blended_total())
);
}
}
}
fn escape_command(command: &[String]) -> String {

View File

@@ -5,7 +5,7 @@ use std::sync::atomic::AtomicU64;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::handle_last_message;
use crate::exec_events::AssistantMessageItem;
use crate::exec_events::AgentMessageItem;
use crate::exec_events::CommandExecutionItem;
use crate::exec_events::CommandExecutionStatus;
use crate::exec_events::FileChangeItem;
@@ -162,7 +162,7 @@ impl EventProcessorWithJsonOutput {
let item = ThreadItem {
id: self.get_next_item_id(),
details: ThreadItemDetails::AssistantMessage(AssistantMessageItem {
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
text: payload.message.clone(),
}),
};

View File

@@ -2,34 +2,45 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Top-level events emitted on the Codex Exec thread stream.
/// Top-level JSONL events emitted by codex exec
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "type")]
pub enum ThreadEvent {
/// Emitted when a new thread is started as the first event.
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
/// Emitted when a turn is started by sending a new prompt to the model.
/// A turn encompasses all events that happen while agent is processing the prompt.
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
/// Emitted when a turn is completed. Typically right after the assistant's response.
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
/// Indicates that a turn failed with an error.
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
/// Emitted when a new item is added to the thread. Typically the item will be in an "in progress" state.
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
/// Emitted when an item is updated.
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent),
/// Signals that an item has reached a terminal state—either success or failure.
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
/// Represents an unrecoverable error emitted directly by the event stream.
#[serde(rename = "error")]
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ThreadStartedEvent {
/// The identified of the new thread. Can be used to resume the thread later.
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct TurnStartedEvent {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
@@ -42,11 +53,14 @@ pub struct TurnFailedEvent {
pub error: ThreadErrorEvent,
}
/// Minimal usage summary for a turn.
/// Describes the usage of tokens during a turn.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct Usage {
/// The number of input tokens used during the turn.
pub input_tokens: u64,
/// The number of cached input tokens used during the turn.
pub cached_input_tokens: u64,
/// The number of output tokens used during the turn.
pub output_tokens: u64,
}
@@ -81,36 +95,46 @@ pub struct ThreadItem {
/// Typed payloads for each supported thread item type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "item_type", rename_all = "snake_case")]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThreadItemDetails {
AssistantMessage(AssistantMessageItem),
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
AgentMessage(AgentMessageItem),
/// Agent's reasoning summary.
Reasoning(ReasoningItem),
/// Tracks a command executed by the agent. The item starts when the command is
/// spawned, and completes when the process exits with an exit code.
CommandExecution(CommandExecutionItem),
/// Represents a set of file changes by the agent. The item is emitted only as a
/// completed event once the patch succeeds or fails.
FileChange(FileChangeItem),
/// Represents a call to an MCP tool. The item starts when the invocation is
/// dispatched and completes when the MCP server reports success or failure.
McpToolCall(McpToolCallItem),
/// Captures a web search request. It starts when the search is kicked off
/// and completes when results are returned to the agent.
WebSearch(WebSearchItem),
/// Tracks the agent's running to-do list. It starts when the plan is first
/// issued, updates as steps change state, and completes when the turn ends.
TodoList(TodoListItem),
/// Describes a non-fatal error surfaced as an item.
Error(ErrorItem),
}
/// Session metadata.
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionItem {
pub session_id: String,
}
/// Assistant message payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct AssistantMessageItem {
pub struct AgentMessageItem {
pub text: String,
}
/// Model reasoning summary payload.
/// Agent's reasoning summary.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ReasoningItem {
pub text: String,
}
/// The status of a command execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum CommandExecutionStatus {
@@ -120,7 +144,7 @@ pub enum CommandExecutionStatus {
Failed,
}
/// Local shell command execution payload.
/// A command executed by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct CommandExecutionItem {
pub command: String,
@@ -130,13 +154,14 @@ pub struct CommandExecutionItem {
pub status: CommandExecutionStatus,
}
/// Single file change summary for a patch.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileUpdateChange {
pub path: String,
pub kind: PatchChangeKind,
}
/// The status of a file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
@@ -144,14 +169,14 @@ pub enum PatchApplyStatus {
Failed,
}
/// Patch application payload.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileChangeItem {
pub changes: Vec<FileUpdateChange>,
pub status: PatchApplyStatus,
}
/// Known change kinds for a patch.
/// Indicates the type of the file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchChangeKind {
@@ -160,6 +185,7 @@ pub enum PatchChangeKind {
Update,
}
/// The status of an MCP tool call.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum McpToolCallStatus {
@@ -169,6 +195,7 @@ pub enum McpToolCallStatus {
Failed,
}
/// A call to an MCP tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct McpToolCallItem {
pub server: String,
@@ -176,16 +203,19 @@ pub struct McpToolCallItem {
pub status: McpToolCallStatus,
}
/// A web search request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct WebSearchItem {
pub query: String,
}
/// An error notification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ErrorItem {
pub message: String,
}
/// An item in agent's to-do list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct TodoItem {
pub text: String,

View File

@@ -17,6 +17,7 @@ use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TaskCompleteEvent;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
@@ -236,8 +237,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let conversation_manager =
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
let auth_manager = AuthManager::shared(config.codex_home.clone(), true);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
let NewConversation {
@@ -249,11 +250,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if let Some(path) = resume_path {
conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path,
AuthManager::shared(config.codex_home.clone()),
)
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
.await?
} else {
conversation_manager
@@ -367,6 +364,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
}
}
}
event_processor.print_final_output();
if error_seen {
std::process::exit(1);
}
@@ -379,7 +377,9 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[])
.await
{
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
error!("Error listing conversations: {e}");

View File

@@ -14,7 +14,7 @@ use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput;
use codex_exec::exec_events::AssistantMessageItem;
use codex_exec::exec_events::AgentMessageItem;
use codex_exec::exec_events::CommandExecutionItem;
use codex_exec::exec_events::CommandExecutionStatus;
use codex_exec::exec_events::ItemCompletedEvent;
@@ -410,7 +410,7 @@ fn agent_reasoning_produces_item_completed_reasoning() {
}
#[test]
fn agent_message_produces_item_completed_assistant_message() {
fn agent_message_produces_item_completed_agent_message() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let ev = event(
"e1",
@@ -424,7 +424,7 @@ fn agent_message_produces_item_completed_assistant_message() {
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::AssistantMessage(AssistantMessageItem {
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
text: "hello".to_string(),
}),
},

View File

@@ -0,0 +1,34 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use core_test_support::responses::ev_completed;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex_exec::test_codex_exec;
use wiremock::Mock;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> {
let test = test_codex_exec();
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header("Authorization", "Bearer dummy"))
.respond_with(sse_response(sse(vec![ev_completed("request_0")])))
.expect(1)
.mount(&server)
.await;
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("echo testing codex api key")
.assert()
.success();
Ok(())
}

View File

@@ -1,5 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod auth_env;
mod output_schema;
mod resume;
mod sandbox;

View File

@@ -1,10 +1,9 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use core_test_support::test_codex_exec::test_codex_exec;
use serde_json::Value;
use std::process::Command;
use std::path::Path;
use std::string::ToString;
use tempfile::TempDir;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -72,18 +71,15 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
#[test]
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session with a unique marker in the content.
let marker = format!("resume-last-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -94,7 +90,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.success();
// Find the created session file containing the marker.
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
@@ -102,11 +98,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-last-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -114,8 +106,9 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg("--last");
cmd.assert().success();
.arg("--last")
.assert()
.success();
// Ensure the same file was updated and contains both markers.
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
@@ -132,18 +125,15 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session
let marker = format!("resume-by-id-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -153,7 +143,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let session_id = extract_conversation_id(&path);
@@ -166,11 +156,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -178,8 +164,9 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg(&session_id);
cmd.assert().success();
.arg(&session_id)
.assert()
.success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
@@ -195,17 +182,14 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-config-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -219,17 +203,15 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let marker2 = format!("resume-config-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let output = Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
let output = test
.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")

View File

@@ -14,6 +14,7 @@ pub use codex_core::AuthManager;
pub use codex_core::CodexAuth;
pub use codex_core::auth::AuthDotJson;
pub use codex_core::auth::CLIENT_ID;
pub use codex_core::auth::CODEX_API_KEY_ENV_VAR;
pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
pub use codex_core::auth::get_auth_file;
pub use codex_core::auth::login_with_api_key;

View File

@@ -25,6 +25,7 @@ use tiny_http::Header;
use tiny_http::Request;
use tiny_http::Response;
use tiny_http::Server;
use tiny_http::StatusCode;
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
const DEFAULT_PORT: u16 = 1455;
@@ -148,8 +149,15 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::ResponseAndExit { response, result } => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
HandledRequest::ResponseAndExit {
headers,
body,
result,
} => {
let _ = tokio::task::spawn_blocking(move || {
send_response_with_disconnect(req, headers, body)
})
.await;
Some(result)
}
HandledRequest::RedirectWithHeader(header) => {
@@ -185,7 +193,8 @@ enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
response: Response<Cursor<Vec<u8>>>,
headers: Vec<Header>,
body: Vec<u8>,
result: io::Result<()>,
},
}
@@ -275,20 +284,21 @@ async fn process_request(
}
"/success" => {
let body = include_str!("assets/success.html");
let mut resp = Response::from_data(body.as_bytes());
if let Ok(h) = tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
) {
resp.add_header(h);
}
HandledRequest::ResponseAndExit {
response: resp,
headers: match Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
) {
Ok(header) => vec![header],
Err(_) => Vec::new(),
},
body: body.as_bytes().to_vec(),
result: Ok(()),
}
}
"/cancel" => HandledRequest::ResponseAndExit {
response: Response::from_string("Login cancelled"),
headers: Vec::new(),
body: b"Login cancelled".to_vec(),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Login cancelled",
@@ -298,6 +308,50 @@ async fn process_request(
}
}
/// tiny_http filters `Connection` headers out of `Response` objects, so using
/// `req.respond` never informs the client (or the library) that a keep-alive
/// socket should be closed. That leaves the per-connection worker parked in a
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_https response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// server-side connection persistence, but it does not.
fn send_response_with_disconnect(
req: Request,
mut headers: Vec<Header>,
body: Vec<u8>,
) -> io::Result<()> {
let status = StatusCode(200);
let mut writer = req.into_writer();
let reason = status.default_reason_phrase();
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
headers.retain(|h| !h.field.equiv("Connection"));
if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
headers.push(close_header);
}
let content_length_value = format!("{}", body.len());
if let Ok(content_length_header) =
Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
{
headers.push(content_length_header);
}
for header in headers {
write!(
writer,
"{}: {}\r\n",
header.field.as_str(),
header.value.as_str()
)?;
}
writer.write_all(b"\r\n")?;
writer.write_all(&body)?;
writer.flush()
}
fn build_authorize_url(
issuer: &str,
client_id: &str,

View File

@@ -280,6 +280,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::ConversationPath(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_) => {
// For now, we do not do anything extra for these

View File

@@ -8,6 +8,7 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -52,8 +53,9 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone());
let conversation_manager = Arc::new(ConversationManager::new(auth_manager));
let auth_manager = AuthManager::shared(config.codex_home.clone(), false);
let conversation_manager =
Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp));
Self {
outgoing,
initialized: false,

View File

@@ -477,6 +477,9 @@ pub enum EventMsg {
ExecCommandEnd(ExecCommandEndEvent),
/// Notification that the agent attached a local image via the view_image tool.
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -916,7 +919,20 @@ impl InitialHistory {
}
}
#[derive(Serialize, Deserialize, Clone, Default, Debug, TS)]
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum SessionSource {
Cli,
#[default]
VSCode,
Exec,
Mcp,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
pub struct SessionMeta {
pub id: ConversationId,
pub timestamp: String,
@@ -924,6 +940,22 @@ pub struct SessionMeta {
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
#[serde(default)]
pub source: SessionSource,
}
impl Default for SessionMeta {
fn default() -> Self {
SessionMeta {
id: ConversationId::default(),
timestamp: String::new(),
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
instructions: None,
source: SessionSource::default(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
@@ -1074,6 +1106,14 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ViewImageToolCallEvent {
/// Identifier for the originating tool call.
pub call_id: String,
/// Local filesystem path provided to the tool.
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {

View File

@@ -22,7 +22,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
help="Print the version that would be used and exit before making changes.",
)
group = parser.add_mutually_exclusive_group(required=True)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--publish-alpha",
action="store_true",
@@ -33,13 +33,30 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Publish the next stable release by bumping the minor version.",
)
return parser.parse_args(argv[1:])
parser.add_argument(
"--emergency-version-override",
help="Publish a specific version because tag was created for the previous release but it never succeeded. Value should be semver, e.g., `0.43.0-alpha.9`.",
)
args = parser.parse_args(argv[1:])
if not (
args.publish_alpha
or args.publish_release
or args.emergency_version_override
):
parser.error(
"Must specify --publish-alpha, --publish-release, or --emergency-version-override."
)
return args
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
version = determine_version(args)
if args.emergency_version_override:
version = args.emergency_version_override
else:
version = determine_version(args)
print(f"Publishing version {version}")
if args.dry_run:
return 0

View File

@@ -44,6 +44,7 @@ color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
itertools = { workspace = true }
lazy_static = { workspace = true }

View File

@@ -1,10 +1,14 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
@@ -14,6 +18,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config::persist_model_selection;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TokenUsage;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::ConversationId;
@@ -82,7 +87,10 @@ impl App {
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
));
let enhanced_keys_supported = tui.enhanced_keys_supported();
@@ -292,7 +300,7 @@ impl App {
} else {
text.lines().map(ansi_escape_line).collect()
};
self.overlay = Some(Overlay::new_static_with_title(
self.overlay = Some(Overlay::new_static_with_lines(
pager_lines,
"D I F F".to_string(),
));
@@ -316,20 +324,30 @@ impl App {
self.config.model_family = family;
}
}
AppEvent::OpenReasoningPopup { model, presets } => {
self.chat_widget.open_reasoning_popup(model, presets);
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match persist_model_selection(&self.config.codex_home, profile, &model, effort)
.await
{
Ok(()) => {
let effort_label = effort
.map(|eff| format!(" with {eff} reasoning"))
.unwrap_or_else(|| " with default reasoning".to_string());
if let Some(profile) = profile {
self.chat_widget.add_info_message(
format!("Model changed to {model} for {profile} profile"),
format!(
"Model changed to {model}{effort_label} for {profile} profile"
),
None,
);
} else {
self.chat_widget
.add_info_message(format!("Model changed to {model}"), None);
self.chat_widget.add_info_message(
format!("Model changed to {model}{effort_label}"),
None,
);
}
}
Err(err) => {
@@ -363,6 +381,25 @@ impl App {
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();
let diff_summary = DiffSummary::new(changes, cwd);
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
));
}
ApprovalRequest::Exec { command, .. } => {
let _ = tui.enter_alt_screen();
let full_cmd = strip_bash_lc_and_escape(&command);
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
));
}
},
}
Ok(true)
}

View File

@@ -1,9 +1,11 @@
use std::path::PathBuf;
use codex_common::model_presets::ModelPreset;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crate::bottom_pane::ApprovalRequest;
use crate::history_cell::HistoryCell;
use codex_core::protocol::AskForApproval;
@@ -59,6 +61,12 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: String,
presets: Vec<ModelPreset>,
},
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),
@@ -76,4 +84,7 @@ pub(crate) enum AppEvent {
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
}

View File

@@ -1,16 +1,23 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::HeaderLine;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::text_formatting::truncate_text;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
@@ -22,8 +29,11 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
/// Request coming from the agent that needs user approval.
#[derive(Clone, Debug)]
pub(crate) enum ApprovalRequest {
Exec {
id: String,
@@ -33,13 +43,15 @@ pub(crate) enum ApprovalRequest {
ApplyPatch {
id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
cwd: PathBuf,
changes: HashMap<PathBuf, FileChange>,
},
}
/// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay {
current: Option<ApprovalRequestState>,
current_request: Option<ApprovalRequest>,
current_variant: Option<ApprovalVariant>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
@@ -51,23 +63,16 @@ pub(crate) struct ApprovalOverlay {
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let mut view = Self {
current: Some(ApprovalRequestState::from(request)),
current_request: None,
current_variant: None,
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(
SelectionViewParams {
title: String::new(),
..Default::default()
},
app_event_tx,
),
list: ListSelectionView::new(Default::default(), app_event_tx),
options: Vec::new(),
current_complete: false,
done: false,
};
let (options, params) = view.build_options();
view.options = options;
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
view.set_current(request);
view
}
@@ -76,45 +81,56 @@ impl ApprovalOverlay {
}
fn set_current(&mut self, request: ApprovalRequest) {
self.current = Some(ApprovalRequestState::from(request));
self.current_request = Some(request.clone());
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
self.current_variant = Some(variant.clone());
self.current_complete = false;
let (options, params) = self.build_options();
let (options, params) = Self::build_options(variant, header);
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
}
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
let Some(state) = self.current.as_ref() else {
return (
Vec::new(),
SelectionViewParams {
title: String::new(),
..Default::default()
},
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
fn build_options(
variant: ApprovalVariant,
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { .. } => (
exec_options(),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
patch_options(),
"Would you like to make the following edits?".to_string(),
),
};
let header = Box::new(ColumnRenderable::new([
Box::new(Line::from(title.bold())),
Box::new(Line::from("")),
header,
]));
let items = options
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
description: Some(opt.description.clone()),
is_current: false,
actions: Vec::new(),
display_shortcut: opt.display_shortcut,
dismiss_on_select: false,
search_value: None,
..Default::default()
})
.collect();
let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
footer_hint: Some(Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".into(),
])),
items,
header: state.header.clone(),
header,
..Default::default()
};
@@ -128,8 +144,8 @@ impl ApprovalOverlay {
let Some(option) = self.options.get(actual_idx) else {
return;
};
if let Some(state) = self.current.as_ref() {
match (&state.variant, option.decision) {
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
}
@@ -171,30 +187,33 @@ impl ApprovalOverlay {
}
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = key_event
else {
return false;
};
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
return false;
}
let lower = c.to_ascii_lowercase();
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
self.apply_selection(idx);
true
} else {
false
match key_event {
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char('a'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(request) = self.current_request.as_ref() {
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
true
} else {
false
}
}
e => {
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
}
}
@@ -215,9 +234,9 @@ impl BottomPaneView for ApprovalOverlay {
return CancellationEvent::Handled;
}
if !self.current_complete
&& let Some(state) = self.current.as_ref()
&& let Some(variant) = self.current_variant.as_ref()
{
match &state.variant {
match &variant {
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
@@ -235,14 +254,6 @@ impl BottomPaneView for ApprovalOverlay {
self.done
}
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
@@ -256,9 +267,19 @@ impl BottomPaneView for ApprovalOverlay {
}
}
impl Renderable for ApprovalOverlay {
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
}
struct ApprovalRequestState {
variant: ApprovalVariant,
header: Vec<HeaderLine>,
header: Box<dyn Renderable>,
}
impl From<ApprovalRequest> for ApprovalRequestState {
@@ -269,63 +290,50 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
} => {
let mut header = Vec::new();
let mut header: Vec<Line<'static>> = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
header.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
header.push(Line::from(""));
}
let command_snippet = exec_snippet(&command);
if !command_snippet.is_empty() {
header.push(HeaderLine::Text {
text: format!("Command: {command_snippet}"),
italic: false,
});
header.push(HeaderLine::Spacer);
let full_cmd = strip_bash_lc_and_escape(&command);
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
if let Some(first) = full_cmd_lines.first_mut() {
first.spans.insert(0, Span::from("$ "));
}
header.extend(full_cmd_lines);
Self {
variant: ApprovalVariant::Exec { id, command },
header,
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
ApprovalRequest::ApplyPatch {
id,
reason,
grant_root,
cwd,
changes,
} => {
let mut header = Vec::new();
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
header.push(DiffSummary::new(changes, cwd).into());
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
if let Some(root) = grant_root {
header.push(HeaderLine::Text {
text: format!(
"Grant write access to {} for the remainder of this session.",
root.display()
),
italic: false,
});
header.push(HeaderLine::Spacer);
header.push(Box::new(Line::from("")));
header.push(Box::new(
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
));
}
Self {
variant: ApprovalVariant::ApplyPatch { id },
header,
header: Box::new(ColumnRenderable::new(header)),
}
}
}
}
}
#[derive(Clone)]
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
@@ -334,31 +342,38 @@ enum ApprovalVariant {
#[derive(Clone)]
struct ApprovalOption {
label: String,
description: String,
decision: ReviewDecision,
shortcut: Option<char>,
display_shortcut: Option<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
}
impl ApprovalOption {
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
self.display_shortcut
.into_iter()
.chain(self.additional_shortcuts.iter().copied())
}
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve and run now".to_string(),
description: "(Y) Run this command one time".to_string(),
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Always approve this session".to_string(),
description: "(A) Automatically approve this command for the rest of the session"
.to_string(),
label: "Yes, and don't ask again for this command".to_string(),
decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not run the command".to_string(),
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}
@@ -366,16 +381,16 @@ fn exec_options() -> Vec<ApprovalOption> {
fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve".to_string(),
description: "(Y) Apply the proposed changes".to_string(),
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not apply the changes".to_string(),
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}
@@ -516,8 +531,8 @@ mod tests {
};
let view = ApprovalOverlay::new(exec_request, tx);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
view.render(Rect::new(0, 0, 80, 6), &mut buf);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -529,7 +544,7 @@ mod tests {
assert!(
rendered
.iter()
.any(|line| line.contains("Command: echo hello world")),
.any(|line| line.contains("echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}

View File

@@ -1,12 +1,12 @@
use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView {
pub(crate) trait BottomPaneView: Renderable {
/// Handle a key event while the view is active. A redraw is always
/// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
@@ -21,12 +21,6 @@ pub(crate) trait BottomPaneView {
CancellationEvent::NotHandled
}
/// Return the desired height of the view.
fn desired_height(&self, width: u16) -> u16;
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pasted: String) -> bool {

View File

@@ -6,6 +6,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;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_common::fuzzy_match::fuzzy_match;
@@ -171,6 +173,7 @@ impl CommandPopup {
name,
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
display_shortcut: None,
description: Some(description),
}
})
@@ -205,13 +208,12 @@ impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered());
render_rows(
area,
area.inset(Insets::tlbr(0, 2, 0, 0)),
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
false,
);
}
}

View File

@@ -12,7 +12,9 @@ use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::render::renderable::Renderable;
use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -94,6 +96,36 @@ impl BottomPaneView for CustomPromptView {
self.complete
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
@@ -189,7 +221,7 @@ impl BottomPaneView for CustomPromptView {
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(STANDARD_POPUP_HINT_LINE).render(
Paragraph::new(standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
@@ -200,34 +232,6 @@ impl BottomPaneView for CustomPromptView {
);
}
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl CustomPromptView {

View File

@@ -3,6 +3,9 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
@@ -127,6 +130,7 @@ impl WidgetRef for &FileSearchPopup {
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
display_shortcut: None,
description: None,
})
.collect()
@@ -139,13 +143,12 @@ impl WidgetRef for &FileSearchPopup {
};
render_rows(
area,
area.inset(Insets::tlbr(0, 2, 0, 0)),
buf,
&rows_all,
&self.state,
MAX_POPUP_ROWS,
empty_message,
false,
);
}
}

View File

@@ -1,13 +1,15 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::line_utils::prefix_lines;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
use std::iter;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
@@ -61,15 +63,12 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 {
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
let lines = footer_lines(props);
for (idx, line) in lines.into_iter().enumerate() {
let y = area.y + idx as u16;
if y >= area.y + area.height {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
Paragraph::new(prefix_lines(
footer_lines(props),
" ".repeat(FOOTER_INDENT_COLS).into(),
" ".repeat(FOOTER_INDENT_COLS).into(),
))
.render(area, buf);
}
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
@@ -81,7 +80,10 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
if props.is_task_running {
vec![context_window_line(props.context_window_percent)]
} else {
vec![dim_line(indent_text("? for shortcuts"))]
vec![Line::from(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
])]
}
}
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
@@ -110,27 +112,36 @@ fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
} else {
"quit"
};
let text = format!("ctrl + c again to {action}");
dim_line(indent_text(&text))
Line::from(vec![
key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
let text = if esc_backtrack_hint {
"esc again to edit previous message"
let esc = key_hint::plain(KeyCode::Esc);
if esc_backtrack_hint {
Line::from(vec![esc.into(), " again to edit previous message".into()]).dim()
} else {
"esc esc to edit previous message"
};
dim_line(indent_text(text))
Line::from(vec![
esc.into(),
" ".into(),
esc.into(),
" to edit previous message".into(),
])
.dim()
}
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = String::new();
let mut newline = String::new();
let mut file_paths = String::new();
let mut paste_image = String::new();
let mut edit_previous = String::new();
let mut quit = String::new();
let mut show_transcript = String::new();
let mut commands = Line::from("");
let mut newline = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) {
@@ -153,14 +164,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
paste_image,
edit_previous,
quit,
String::new(),
Line::from(""),
show_transcript,
];
build_columns(ordered)
}
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
@@ -174,7 +185,7 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
let mut entries = entries;
if entries.len() < target_len {
entries.extend(std::iter::repeat_n(
String::new(),
Line::from(""),
target_len - entries.len(),
));
}
@@ -183,7 +194,7 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.len());
column_widths[column] = column_widths[column].max(entry.width());
}
for (idx, width) in column_widths.iter_mut().enumerate() {
@@ -193,42 +204,30 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
entries
.chunks(COLUMNS)
.map(|chunk| {
let mut line = String::new();
let mut line = Line::from("");
for (col, entry) in chunk.iter().enumerate() {
line.push_str(entry);
line.extend(entry.spans.clone());
if col < COLUMNS - 1 {
let target_width = column_widths[col];
let padding = target_width.saturating_sub(entry.len()) + COLUMN_GAP;
line.push_str(&" ".repeat(padding));
let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP;
line.push_span(Span::from(" ".repeat(padding)));
}
}
let indented = indent_text(&line);
dim_line(indented)
line.dim()
})
.collect()
}
fn indent_text(text: &str) -> String {
let mut indented = String::with_capacity(FOOTER_INDENT_COLS + text.len());
indented.extend(iter::repeat_n(' ', FOOTER_INDENT_COLS));
indented.push_str(text);
indented
}
fn dim_line(text: String) -> Line<'static> {
Line::from(text).dim()
}
fn context_window_line(percent: Option<u8>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(indent_text("").into());
match percent {
Some(percent) => {
spans.push(format!("{percent}%").bold());
spans.push(" context left".dim());
}
None => {
spans.push("? for shortcuts".dim());
spans.push(key_hint::plain(KeyCode::Char('?')).into());
spans.push(" for shortcuts".dim());
}
}
Line::from(spans)
@@ -247,9 +246,7 @@ enum ShortcutId {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ShortcutBinding {
code: KeyCode,
modifiers: KeyModifiers,
overlay_text: &'static str,
key: KeyBinding,
condition: DisplayCondition,
}
@@ -288,20 +285,24 @@ impl ShortcutDescriptor {
self.bindings.iter().find(|binding| binding.matches(state))
}
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
let binding = self.binding_for(state)?;
let label = match self.id {
let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
match self.id {
ShortcutId::EditPrevious => {
if state.esc_backtrack_hint {
" again to edit previous message"
line.push_span(" again to edit previous message");
} else {
" esc to edit previous message"
line.extend(vec![
" ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to edit previous message".into(),
]);
}
}
_ => self.label,
_ => line.push_span(self.label),
};
let text = format!("{}{}{}", self.prefix, binding.overlay_text, label);
Some(text)
Some(line)
}
}
@@ -309,9 +310,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Commands,
bindings: &[ShortcutBinding {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
overlay_text: "/",
key: key_hint::plain(KeyCode::Char('/')),
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -321,15 +320,11 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
id: ShortcutId::InsertNewline,
bindings: &[
ShortcutBinding {
code: KeyCode::Enter,
modifiers: KeyModifiers::SHIFT,
overlay_text: "shift + enter",
key: key_hint::shift(KeyCode::Enter),
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + j",
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
@@ -339,9 +334,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {
code: KeyCode::Char('@'),
modifiers: KeyModifiers::NONE,
overlay_text: "@",
key: key_hint::plain(KeyCode::Char('@')),
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -350,9 +343,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::PasteImage,
bindings: &[ShortcutBinding {
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + v",
key: key_hint::ctrl(KeyCode::Char('v')),
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -361,9 +352,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
overlay_text: "esc",
key: key_hint::plain(KeyCode::Esc),
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -372,9 +361,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + c",
key: key_hint::ctrl(KeyCode::Char('c')),
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -383,9 +370,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + t",
key: key_hint::ctrl(KeyCode::Char('t')),
condition: DisplayCondition::Always,
}],
prefix: "",

View File

@@ -1,16 +1,26 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use itertools::Itertools as _;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_palette;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -23,14 +33,10 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Spacer,
}
#[derive(Default)]
pub(crate) struct SelectionItem {
pub name: String,
pub display_shortcut: Option<KeyBinding>,
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
@@ -38,21 +44,32 @@ pub(crate) struct SelectionItem {
pub search_value: Option<String>,
}
#[derive(Default)]
pub(crate) struct SelectionViewParams {
pub title: String,
pub title: Option<String>,
pub subtitle: Option<String>,
pub footer_hint: Option<String>,
pub footer_hint: Option<Line<'static>>,
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub header: Vec<HeaderLine>,
pub header: Box<dyn Renderable>,
}
impl Default for SelectionViewParams {
fn default() -> Self {
Self {
title: None,
subtitle: None,
footer_hint: None,
items: Vec::new(),
is_searchable: false,
search_placeholder: None,
header: Box::new(()),
}
}
}
pub(crate) struct ListSelectionView {
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
state: ScrollState,
complete: bool,
@@ -62,23 +79,22 @@ pub(crate) struct ListSelectionView {
search_placeholder: Option<String>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
header: Vec<HeaderLine>,
header: Box<dyn Renderable>,
}
impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"".dim()
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
let mut header = params.header;
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
header = Box::new(ColumnRenderable::new([
header,
Box::new(title),
Box::new(subtitle),
]));
}
let mut s = Self {
title: params.title,
subtitle: params.subtitle,
footer_hint: params.footer_hint,
items: params.items,
state: ScrollState::new(),
@@ -93,7 +109,7 @@ impl ListSelectionView {
},
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
header: params.header,
header,
};
s.apply_filter();
s
@@ -123,18 +139,10 @@ impl ListSelectionView {
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let matches = if let Some(search_value) = &item.search_value {
search_value.to_lowercase().contains(&query_lower)
} else {
let mut matches = item.name.to_lowercase().contains(&query_lower);
if !matches && let Some(desc) = &item.description {
matches = desc.to_lowercase().contains(&query_lower);
}
matches
};
matches.then_some(idx)
.positions(|item| {
item.search_value
.as_ref()
.is_some_and(|v| v.to_lowercase().contains(&query_lower))
})
.collect();
} else {
@@ -171,7 +179,7 @@ impl ListSelectionView {
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '>' } else { ' ' };
let prefix = if is_selected { '' } else { ' ' };
let name = item.name.as_str();
let name_with_marker = if item.is_current {
format!("{name} (current)")
@@ -179,9 +187,16 @@ impl ListSelectionView {
item.name.clone()
};
let n = visible_idx + 1;
let display_name = format!("{prefix} {n}. {name_with_marker}");
let display_name = if self.is_searchable {
// The number keys don't work when search is enabled (since we let the
// numbers be used for the search query).
format!("{prefix} {name_with_marker}")
} else {
format!("{prefix} {n}. {name_with_marker}")
};
GenericDisplayRow {
name: display_name,
display_shortcut: item.display_shortcut,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
@@ -231,39 +246,6 @@ impl ListSelectionView {
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
self.last_selected_actual_idx.take()
}
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
continue;
}
for part in wrap(text, available) {
let span = if *italic {
Span::from(part.into_owned()).italic()
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
}
}
}
}
lines
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
}
impl BottomPaneView for ListSelectionView {
@@ -299,6 +281,24 @@ impl BottomPaneView for ListSelectionView {
self.search_query.push(c);
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !self.is_searchable
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
&& idx < self.items.len()
{
self.state.selected_idx = Some(idx);
self.accept();
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -316,7 +316,9 @@ impl BottomPaneView for ListSelectionView {
self.complete = true;
CancellationEvent::Handled
}
}
impl Renderable for ListSelectionView {
fn desired_height(&self, width: u16) -> u16 {
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
// Build the same display rows used by the renderer so wrapping math matches.
@@ -324,19 +326,14 @@ impl BottomPaneView for ListSelectionView {
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
// +1 for the title row, +1 for a spacer line beneath the header,
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
// Subtract 4 for the padding on the left and right of the header.
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
if self.is_searchable {
height = height.saturating_add(1);
}
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
}
if self.footer_hint.is_some() {
height = height.saturating_add(2);
height = height.saturating_add(1);
}
height
}
@@ -346,52 +343,45 @@ impl BottomPaneView for ListSelectionView {
return;
}
let mut next_y = area.y;
let header_spans = self.header_spans_for_width(area.width);
for spans in header_spans.into_iter() {
if next_y >= area.y + area.height {
return;
}
let row = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
if spans.is_empty() {
prefixed.push(String::new().into());
} else {
prefixed.extend(spans);
}
Paragraph::new(Line::from(prefixed)).render(row, buf);
next_y = next_y.saturating_add(1);
let [content_area, footer_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
])
.areas(area);
Block::default()
.style(user_message_style(terminal_palette::default_bg()))
.render(content_area, buf);
let header_height = self
.header
// Subtract 4 for the padding on the left and right of the header.
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_height =
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
if header_area.height < header_height {
let [header_area, elision_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
self.header.render(header_area, buf);
Paragraph::new(vec![
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
])
.render(elision_area, buf);
} else {
self.header.render(header_area, buf);
}
if next_y >= area.y + area.height {
return;
}
let title_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
if self.is_searchable {
Line::from(self.search_query.clone()).render(search_area, buf);
let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder
.as_ref()
@@ -400,83 +390,43 @@ impl BottomPaneView for ListSelectionView {
} else {
self.search_query.clone().into()
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
.render(search_area, buf);
next_y = next_y.saturating_add(1);
Line::from(query_span).render(search_area, buf);
}
if let Some(sub) = &self.subtitle {
if next_y >= area.y + area.height {
return;
}
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
if list_area.height > 0 {
let list_area = Rect {
x: list_area.x - 2,
y: list_area.y,
width: list_area.width + 2,
height: list_area.height,
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Self::render_dim_prefix_line(spacer_area, buf);
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: area
.height
.saturating_sub(next_y.saturating_sub(area.y))
.saturating_sub(footer_reserved),
};
let rows = self.build_rows();
if rows_area.height > 0 {
render_rows(
rows_area,
list_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
list_area.height as usize,
"no matches",
true,
);
}
if let Some(hint) = &self.footer_hint {
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
hint.clone().dim().render(hint_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::BottomPaneView;
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -489,24 +439,22 @@ mod tests {
name: "Read Only".to_string(),
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
},
SelectionItem {
name: "Full Access".to_string(),
description: Some("Codex can edit files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
},
];
ListSelectionView::new(
SelectionViewParams {
title: "Select Approval Mode".to_string(),
title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
},
@@ -516,7 +464,7 @@ mod tests {
fn render_lines(view: &ListSelectionView) -> String {
let width = 48;
let height = BottomPaneView::desired_height(view, width);
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
@@ -561,14 +509,13 @@ mod tests {
name: "Read Only".to_string(),
description: Some("Codex can read files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
}];
let mut view = ListSelectionView::new(
SelectionViewParams {
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
@@ -579,6 +526,9 @@ mod tests {
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(lines.contains("▌ filters"));
assert!(
lines.contains("filters"),
"expected search query line to include rendered query, got {lines:?}"
);
}
}

View File

@@ -339,7 +339,14 @@ impl BottomPane {
self.request_redraw();
} else {
// Hide the status indicator when a task completes, but keep other modal views.
self.status = None;
self.hide_status_indicator();
}
}
/// Hide the status indicator while leaving task-running state untouched.
pub(crate) fn hide_status_indicator(&mut self) {
if self.status.take().is_some() {
self.request_redraw();
}
}

View File

@@ -1,8 +1,21 @@
//! Shared popup-related constants for bottom pane widgets.
use crossterm::event::KeyCode;
use ratatui::text::Line;
use crate::key_hint;
/// 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;
/// Standard footer hint text used by popups.
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back";
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
}

View File

@@ -3,28 +3,25 @@ use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
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::Widget;
use unicode_width::UnicodeWidthChar;
use crate::key_hint::KeyBinding;
use super::scroll_state::ScrollState;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub display_shortcut: Option<KeyBinding>,
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 {}
/// Compute a shared description-column start based on the widest visible name
/// plus two spaces of padding. Ensures at least one column is left for the
/// description.
@@ -98,6 +95,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let this_name_width = Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = name_spans;
if let Some(display_shortcut) = row.display_shortcut {
full_spans.push(" ".into());
full_spans.push(display_shortcut.into());
}
if let Some(desc) = row.description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
if gap > 0 {
@@ -117,71 +118,19 @@ pub(crate) fn render_rows(
state: &ScrollState,
max_results: usize,
empty_message: &str,
include_border: bool,
) {
if include_border {
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
// Always draw a dim left border to match other popups.
let block = Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM));
block.render(area, buf);
}
// Content renders to the right of the border with the same live prefix
// padding used by the composer so the popup aligns with the input text.
let prefix_cols = LIVE_PREFIX_COLS;
let content_area = Rect {
x: area.x.saturating_add(prefix_cols),
y: area.y,
width: area.width.saturating_sub(prefix_cols),
height: area.height,
};
// Clear the padding column(s) so stale characters never peek between the
// border and the popup contents.
let padding_cols = prefix_cols.saturating_sub(1);
if padding_cols > 0 {
let pad_start = area.x.saturating_add(1);
let pad_end = pad_start
.saturating_add(padding_cols)
.min(area.x.saturating_add(area.width));
let pad_bottom = area.y.saturating_add(area.height);
for x in pad_start..pad_end {
for y in area.y..pad_bottom {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(" ");
}
}
}
}
if rows_all.is_empty() {
if content_area.height > 0 {
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
para.render(
Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
},
buf,
);
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
}
return;
}
// Determine which logical rows (items) are visible given the selection and
// the max_results clamp. Scrolling is still item-based for simplicity.
let max_rows_from_area = content_area.height as usize;
let visible_items = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
.min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
@@ -195,63 +144,64 @@ pub(crate) fn render_rows(
}
}
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
let mut cur_y = content_area.y;
let mut cur_y = area.y;
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
{
if cur_y >= content_area.y + content_area.height {
if cur_y >= area.y + area.height {
break;
}
let GenericDisplayRow {
name,
match_indices,
display_shortcut,
is_current: _is_current,
description,
} = row;
let full_line = build_full_line(
let mut full_line = build_full_line(
&GenericDisplayRow {
name: name.clone(),
match_indices: match_indices.clone(),
display_shortcut: *display_shortcut,
is_current: *_is_current,
description: description.clone(),
},
desc_col,
);
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.fg(Color::Cyan).bold();
});
}
// Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let options = RtOptions::new(content_area.width as usize)
let options = RtOptions::new(area.width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col)));
let wrapped = word_wrap_line(&full_line, options);
// Render the wrapped lines.
for mut line in wrapped {
if cur_y >= content_area.y + content_area.height {
for line in wrapped {
if cur_y >= area.y + area.height {
break;
}
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
line.style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
}
let para = Paragraph::new(line);
para.render(
line.render(
Rect {
x: content_area.x,
x: area.x,
y: cur_y,
width: content_area.width,
width: area.width,
height: 1,
},
buf,

View File

@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1497
expression: terminal.backend()
---
" "

View File

@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 389
expression: terminal.backend()
---
" / for commands shift + enter for newline "

View File

@@ -2,10 +2,11 @@
source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view)
---
▌ Select Approval Mode
▌ Switch between Codex approval presets
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back
Select Approval Mode
Switch between Codex approval presets
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press enter to confirm or esc to go back

View File

@@ -2,9 +2,10 @@
source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view)
---
▌ Select Approval Mode
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back
Select Approval Mode
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press enter to confirm or esc to go back

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
@@ -39,6 +40,7 @@ use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::ConversationId;
@@ -68,7 +70,7 @@ use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -79,7 +81,6 @@ use crate::history_cell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PatchEventType;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
@@ -111,6 +112,7 @@ use codex_git_tooling::GhostCommit;
use codex_git_tooling::GitToolingError;
use codex_git_tooling::create_ghost_commit;
use codex_git_tooling::restore_ghost_commit;
use strum::IntoEnumIterator;
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
@@ -283,6 +285,16 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
fn model_description_for(slug: &str) -> Option<&'static str> {
if slug.starts_with("gpt-5-codex") {
Some("Optimized for coding tasks with many tools.")
} else if slug.starts_with("gpt-5") {
Some("Broad world knowledge with strong general reasoning.")
} else {
None
}
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
@@ -534,14 +546,20 @@ impl ChatWidget {
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
event.changes,
&self.config.cwd,
));
}
fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_view_image_tool_call(
event.path,
&self.config.cwd,
));
self.request_redraw();
}
fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
let ev2 = event.clone();
self.defer_or_handle(
@@ -614,7 +632,7 @@ impl ChatWidget {
if let Some(controller) = self.stream_controller.as_mut() {
let (cell, is_idle) = controller.on_commit_tick();
if let Some(cell) = cell {
self.bottom_pane.set_task_running(false);
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
if is_idle {
@@ -647,7 +665,7 @@ impl ChatWidget {
fn handle_stream_finished(&mut self) {
if self.task_complete_pending {
self.bottom_pane.set_task_running(false);
self.bottom_pane.hide_status_indicator();
self.task_complete_pending = false;
}
// A completed stream indicates non-exec content was just inserted.
@@ -736,8 +754,6 @@ impl ChatWidget {
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
// Emit the proposed command into history (like proposed patches)
self.add_to_history(history_cell::new_proposed_command(&ev.command));
let command = shlex::try_join(ev.command.iter().map(String::as_str))
.unwrap_or_else(|_| ev.command.join(" "));
self.notify(Notification::ExecApprovalRequested { command });
@@ -757,16 +773,12 @@ impl ChatWidget {
ev: ApplyPatchApprovalRequestEvent,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
&self.config.cwd,
));
let request = ApprovalRequest::ApplyPatch {
id,
reason: ev.reason,
grant_root: ev.grant_root,
changes: ev.changes.clone(),
cwd: self.config.cwd.clone(),
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
@@ -1408,6 +1420,7 @@ impl ChatWidget {
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev),
EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
@@ -1578,47 +1591,38 @@ impl ChatWidget {
));
}
/// Open a popup to choose the model preset (model + reasoning effort).
/// Open a popup to choose the model (stage 1). After selecting a model,
/// a second popup is shown to choose the reasoning effort.
pub(crate) fn open_model_popup(&mut self) {
let current_model = self.config.model.clone();
let current_effort = self.config.model_reasoning_effort;
let auth_mode = self.auth_manager.auth().map(|auth| auth.mode);
let presets: Vec<ModelPreset> = builtin_model_presets(auth_mode);
let mut grouped: BTreeMap<&str, Vec<ModelPreset>> = BTreeMap::new();
for preset in presets.into_iter() {
grouped.entry(preset.model).or_default().push(preset);
}
let mut items: Vec<SelectionItem> = Vec::new();
for preset in presets.iter() {
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let is_current = preset.model == current_model && preset.effort == current_effort;
let model_slug = preset.model.to_string();
let effort = preset.effort;
let current_model = current_model.clone();
for (model_slug, entries) in grouped.into_iter() {
let name = model_slug.to_string();
let description = Self::model_description_for(model_slug)
.map(std::string::ToString::to_string)
.or_else(|| {
entries
.iter()
.find(|preset| !preset.description.is_empty())
.map(|preset| preset.description.to_string())
})
.or_else(|| entries.first().map(|preset| preset.description.to_string()));
let is_current = model_slug == current_model;
let model_slug_string = model_slug.to_string();
let presets_for_model = entries.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_slug.clone()),
effort: Some(effort),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_slug.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tx.send(AppEvent::PersistModelSelection {
model: model_slug.clone(),
effort,
tx.send(AppEvent::OpenReasoningPopup {
model: model_slug_string.clone(),
presets: presets_for_model.clone(),
});
tracing::info!(
"New model: {}, New effort: {}, Current model: {}, Current effort: {}",
model_slug.clone(),
effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string()),
current_model,
current_effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string())
);
})];
items.push(SelectionItem {
name,
@@ -1626,16 +1630,133 @@ impl ChatWidget {
is_current,
actions,
dismiss_on_select: true,
search_value: None,
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select model and reasoning level".to_string(),
subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select Model".to_string()),
subtitle: Some("Switch the model for this and future Codex CLI sessions".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
pub(crate) fn open_reasoning_popup(&mut self, model_slug: String, presets: Vec<ModelPreset>) {
let default_effort = ReasoningEffortConfig::default();
let has_none_choice = presets.iter().any(|preset| preset.effort.is_none());
struct EffortChoice {
stored: Option<ReasoningEffortConfig>,
display: ReasoningEffortConfig,
}
let mut choices: Vec<EffortChoice> = Vec::new();
for effort in ReasoningEffortConfig::iter() {
if presets.iter().any(|preset| preset.effort == Some(effort)) {
choices.push(EffortChoice {
stored: Some(effort),
display: effort,
});
}
if has_none_choice && default_effort == effort {
choices.push(EffortChoice {
stored: None,
display: effort,
});
}
}
if choices.is_empty() {
choices.push(EffortChoice {
stored: Some(default_effort),
display: default_effort,
});
}
let default_choice: Option<ReasoningEffortConfig> = if has_none_choice {
None
} else if choices
.iter()
.any(|choice| choice.stored == Some(default_effort))
{
Some(default_effort)
} else {
choices
.iter()
.find_map(|choice| choice.stored)
.or(Some(default_effort))
};
let is_current_model = self.config.model == model_slug;
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
default_choice
};
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.display;
let mut effort_label = effort.to_string();
if let Some(first) = effort_label.get_mut(0..1) {
first.make_ascii_uppercase();
}
if choice.stored == default_choice {
effort_label.push_str(" (default)");
}
let description = presets
.iter()
.find(|preset| preset.effort == choice.stored && !preset.description.is_empty())
.map(|preset| preset.description.to_string())
.or_else(|| {
presets
.iter()
.find(|preset| preset.effort == choice.stored)
.map(|preset| preset.description.to_string())
});
let model_for_action = model_slug.clone();
let effort_for_action = choice.stored;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_for_action.clone()),
effort: Some(effort_for_action),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: effort_for_action,
});
tracing::info!(
"Selected model: {}, Selected effort: {}",
model_for_action,
effort_for_action
.map(|e| e.to_string())
.unwrap_or_else(|| "default".to_string())
);
})];
items.push(SelectionItem {
name: effort_label,
description,
is_current: is_current_model && choice.stored == highlight_choice,
actions,
dismiss_on_select: true,
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Reasoning Level".to_string()),
subtitle: Some(format!("Reasoning for model {model_slug}")),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
@@ -1672,13 +1793,13 @@ impl ChatWidget {
is_current,
actions,
dismiss_on_select: true,
search_value: None,
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
@@ -1796,7 +1917,6 @@ impl ChatWidget {
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("(PR Style)".into()),
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
@@ -1804,13 +1924,11 @@ impl ChatWidget {
}
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(
move |tx: &AppEventSender| {
tx.send(AppEvent::CodexOp(Op::Review {
@@ -1822,14 +1940,12 @@ impl ChatWidget {
},
)],
dismiss_on_select: true,
search_value: None,
..Default::default()
});
// New: Review a specific commit (opens commit picker)
items.push(SelectionItem {
name: "Review a commit".to_string(),
description: None,
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
@@ -1837,23 +1953,21 @@ impl ChatWidget {
}
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewCustomPrompt);
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a review preset".into(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select a review preset".into()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
@@ -1870,8 +1984,6 @@ impl ChatWidget {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
@@ -1884,12 +1996,13 @@ impl ChatWidget {
})],
dismiss_on_select: true,
search_value: Some(option),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a base branch".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
@@ -1909,8 +2022,6 @@ impl ChatWidget {
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
@@ -1925,12 +2036,13 @@ impl ChatWidget {
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
@@ -2134,8 +2246,6 @@ pub(crate) fn show_review_commit_picker_with_entries(
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
@@ -2150,12 +2260,13 @@ pub(crate) fn show_review_commit_picker_with_entries(
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),

View File

@@ -2,4 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines)
---
Change Approved foo.txt (+1 -0)
Added foo.txt (+1 -0)
1 +hello

View File

@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_lines)
---
• Proposed Change foo.txt (+1 -0)
1 +hello

View File

@@ -1,18 +1,16 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
expression: terminal.backend().vt100().screen().contents()
---
" "
"▌ this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "
Would you like to run the following command?
Reason: this is a test reason such as one that would be produced by the
model
$ echo hello world
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -1,16 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
expression: terminal.backend().vt100().screen().contents()
---
" "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "
Would you like to run the following command?
$ echo hello world
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -1,16 +1,17 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
expression: terminal.backend().vt100().screen().contents()
---
" "
"▌ The model wants to apply changes "
"▌ "
"▌ Grant write access to /tmp for the remainder of this session. "
"▌ "
"▌ Apply changes? "
"▌ "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the changes "
" "
"Press Enter to confirm or Esc to cancel "
" "
Would you like to make the following edits?
README.md (+2 -0)
1 +hello
2 +world
The model wants to apply changes
1. Yes, proceed
2. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Thinking (0s • Esc to interrupt) "
" Thinking (0s • esc to interrupt) "
" Ask Codex to do anything "

View File

@@ -9,7 +9,7 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved
Read diff_render.rs
Investigating rendering code (0s • Esc to interrupt)
Investigating rendering code (0s • esc to interrupt)
Summarize recent commits

View File

@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_multi)
---
• Proposed Command
└ echo line1
echo line2

View File

@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed)
---
• Proposed Command
└ echo hello world

View File

@@ -0,0 +1,39 @@
---
source: tui/src/chatwidget/tests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 15 },
content: [
" ",
" ",
" Would you like to run the following command? ",
" ",
" Reason: this is a test reason such as one that would be produced by the ",
" model ",
" ",
" $ echo hello world ",
" ",
" 1. Yes, proceed ",
" 2. Yes, and don't ask again for this command ",
" 3. No, and tell Codex what to do differently esc ",
" ",
" Press enter to confirm or esc to cancel ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 17, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Viewed Image
└ example.png

View File

@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Reasoning Level
Reasoning for model gpt-5-codex
1. Low Fastest responses with limited reasoning
2. Medium (default) Dynamically adjusts reasoning based on the task
3. High (current) Maximizes reasoning depth for complex or ambiguous
problems
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Model
Switch the model for this and future Codex CLI sessions
1. gpt-5 Broad world knowledge with strong general
reasoning.
2. gpt-5-codex (current) Optimized for coding tasks with many tools.
Press enter to confirm or esc to go back

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Analyzing (0s • Esc to interrupt) "
" Analyzing (0s • esc to interrupt) "
" "
" "
" Ask Codex to do anything "

View File

@@ -3,16 +3,17 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌ this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo 'hello world' "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" Would you like to run the following command? "
" "
" Reason: this is a test reason such as one that would be produced by the "
" model "
" "
" $ echo 'hello world' "
" "
" 1. Yes, proceed "
" 2. Yes, and don't ask again for this command "
" 3. No, and tell Codex what to do differently esc "
" "
" Press enter to confirm or esc to cancel "
" "

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
@@ -34,6 +35,7 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
use codex_core::protocol::ViewImageToolCallEvent;
use codex_protocol::ConversationId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -45,6 +47,7 @@ use std::io::BufRead;
use std::io::BufReader;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::unbounded_channel;
fn test_config() -> Config {
@@ -82,66 +85,6 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json
payload
}
/*
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80;
// Increased height to keep the initial banner/help lines in view even if
// the session renders an extra header line or minor layout changes occur.
let height: u16 = 2500;
let viewport = Rect::new(0, height - 1, width, 1);
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
// Simulate a streaming answer without any newline characters.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Now simulate the final AgentMessage which should flush the pending line immediately.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Drain history insertions and verify the final line is present.
let cells = drain_insert_history(&mut rx);
assert!(
cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("codex")
}),
"expected 'codex' header to be emitted",
);
let found_final = cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("Hi! How can I help with codex-rs or anything else today?")
});
assert!(
found_final,
"expected final answer text to be flushed to history"
);
}
*/
#[test]
fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
@@ -451,15 +394,18 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
msg: EventMsg::ExecApprovalRequest(ev),
});
// Snapshot the Proposed Command cell emitted into history
let proposed = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed command cell");
assert_snapshot!(
"exec_approval_history_proposed_short",
lines_to_single_string(&proposed)
let proposed_cells = drain_insert_history(&mut rx);
assert!(
proposed_cells.is_empty(),
"expected approval request to render via modal without emitting history cells"
);
// The approval modal should display the command snippet for user confirmation.
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
// Approve via keyboard and verify a concise decision history line is added
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let decision = drain_insert_history(&mut rx)
@@ -475,7 +421,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Multiline command: should render proposed command fully in history with prefixes
// Multiline command: modal should show full command, history records decision only
let ev_multi = ExecApprovalRequestEvent {
call_id: "call-multi".into(),
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
@@ -488,12 +434,29 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
let proposed_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed multiline command cell");
assert_snapshot!(
"exec_approval_history_proposed_multiline",
lines_to_single_string(&proposed_multi)
let proposed_multi = drain_insert_history(&mut rx);
assert!(
proposed_multi.is_empty(),
"expected multiline approval request to render via modal without emitting history cells"
);
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_first_line = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("echo line1") {
saw_first_line = true;
break;
}
}
assert!(
saw_first_line,
"expected modal to show first line of multiline snippet"
);
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
@@ -518,7 +481,11 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
drain_insert_history(&mut rx); // proposed cell not needed for this assertion
let proposed_long = drain_insert_history(&mut rx);
assert!(
proposed_long.is_empty(),
"expected long approval request to avoid emitting history cells before decision"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_long = drain_insert_history(&mut rx)
.pop()
@@ -646,6 +613,36 @@ fn alt_up_edits_most_recent_queued_message() {
);
}
#[test]
fn streaming_final_answer_keeps_task_running_state() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual();
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
assert!(chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_widget().is_none());
chat.bottom_pane
.set_composer_text("queued submission".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued submission"
);
assert!(matches!(op_rx.try_recv(), Err(TryRecvError::Empty)));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
}
#[test]
fn exec_history_cell_shows_working_then_completed() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -829,6 +826,25 @@ fn custom_prompt_enter_empty_does_not_send() {
assert!(rx.try_recv().is_err(), "no app event should be sent");
}
#[test]
fn view_image_tool_call_adds_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
let image_path = chat.config.cwd.join("example.png");
chat.handle_codex_event(Event {
id: "sub-image".into(),
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
call_id: "call-image".into(),
path: image_path,
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
let combined = lines_to_single_string(&cells[0]);
assert_snapshot!("local_image_attachment_history_snapshot", combined);
}
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
// marker (replacing the spinner) and flushes it into history.
#[test]
@@ -934,18 +950,80 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
let mut row = String::new();
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
let y = 1u16.min(height.saturating_sub(1));
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
}
}
if !row.trim().is_empty() {
return row;
}
}
row
String::new()
}
fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
let mut lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
lines.join("\n")
}
#[test]
fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.model = "gpt-5-codex".to_string();
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_selection_popup", popup);
}
#[test]
fn model_reasoning_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.model = "gpt-5-codex".to_string();
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High);
let presets = builtin_model_presets(None)
.into_iter()
.filter(|preset| preset.model == "gpt-5-codex")
.collect::<Vec<_>>();
chat.open_reasoning_popup("gpt-5-codex".to_string(), presets);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_reasoning_selection_popup", popup);
}
#[test]
@@ -1180,12 +1258,27 @@ fn approval_modal_exec_snapshot() {
// Render to a fixed-size test terminal and snapshot.
// Call desired_height first and use that exact height for rendering.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
let mut terminal =
crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height))
.expect("create terminal");
let viewport = Rect::new(0, 0, 80, height);
terminal.set_viewport_area(viewport);
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal");
assert_snapshot!("approval_modal_exec", terminal.backend());
assert!(
terminal
.backend()
.vt100()
.screen()
.contents()
.contains("echo hello world")
);
assert_snapshot!(
"approval_modal_exec",
terminal.backend().vt100().screen().contents()
);
}
// Snapshot test: command approval modal without a reason
@@ -1207,12 +1300,16 @@ fn approval_modal_exec_without_reason_snapshot() {
});
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal (no reason)");
assert_snapshot!("approval_modal_exec_no_reason", terminal.backend());
assert_snapshot!(
"approval_modal_exec_no_reason",
terminal.backend().vt100().screen().contents()
);
}
// Snapshot test: patch approval modal
@@ -1242,12 +1339,16 @@ fn approval_modal_patch_snapshot() {
// Render at the widget's desired height and snapshot.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw patch approval modal");
assert_snapshot!("approval_modal_patch", terminal.backend());
assert_snapshot!(
"approval_modal_patch",
terminal.backend().vt100().screen().contents()
);
}
#[test]
@@ -1469,13 +1570,27 @@ fn apply_patch_events_emit_history_cells() {
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Proposed Change"),
"missing proposed change header: {blob:?}"
cells.is_empty(),
"expected approval request to surface via modal without emitting history cells"
);
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_summary = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("foo.txt (+1 -0)") {
saw_summary = true;
break;
}
}
assert!(saw_summary, "expected approval modal to show diff summary");
// 2) Begin apply -> per-file apply block cell (no global header)
let mut changes2 = HashMap::new();
changes2.insert(
@@ -1561,8 +1676,8 @@ fn apply_patch_manual_approval_adjusts_header() {
assert!(!cells.is_empty(), "expected apply block cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Change Approved foo.txt"),
"expected change approved summary: {blob:?}"
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
"expected apply summary header for foo.txt: {blob:?}"
);
}
@@ -1586,9 +1701,11 @@ fn apply_patch_manual_flow_snapshot() {
grant_root: None,
}),
});
let proposed_lines = drain_insert_history(&mut rx)
.pop()
.expect("proposed patch cell");
let history_before_apply = drain_insert_history(&mut rx);
assert!(
history_before_apply.is_empty(),
"expected approval modal to defer history emission"
);
let mut apply_changes = HashMap::new();
apply_changes.insert(
@@ -1609,10 +1726,6 @@ fn apply_patch_manual_flow_snapshot() {
.pop()
.expect("approved patch cell");
assert_snapshot!(
"apply_patch_manual_flow_history_proposed",
lines_to_single_string(&proposed_lines)
);
assert_snapshot!(
"apply_patch_manual_flow_history_approved",
lines_to_single_string(&approved_lines)
@@ -1765,14 +1878,14 @@ fn apply_patch_untrusted_shows_approval_modal() {
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("Apply changes?") {
if row.contains("Would you like to make the following edits?") {
contains_title = true;
break;
}
}
assert!(
contains_title,
"expected approval modal to be visible with title 'Apply changes?'"
"expected approval modal to be visible with title 'Would you like to make the following edits?'"
);
}
@@ -1802,24 +1915,42 @@ fn apply_patch_request_shows_diff_summary() {
}),
});
// Drain history insertions and verify the diff summary is present
// No history entries yet; the modal should contain the diff summary
let cells = drain_insert_history(&mut rx);
assert!(
!cells.is_empty(),
"expected a history cell with the proposed patch summary"
);
let blob = lines_to_single_string(cells.last().unwrap());
// Header should summarize totals
assert!(
blob.contains("Proposed Change README.md (+2 -0)"),
"missing or incorrect diff header: {blob:?}"
cells.is_empty(),
"expected approval request to render via modal instead of history"
);
// Per-file summary line should include the file path and counts
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_header = false;
let mut saw_line1 = false;
let mut saw_line2 = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("README.md (+2 -0)") {
saw_header = true;
}
if row.contains("+line one") {
saw_line1 = true;
}
if row.contains("+line two") {
saw_line2 = true;
}
if saw_header && saw_line1 && saw_line2 {
break;
}
}
assert!(saw_header, "expected modal to show diff header with totals");
assert!(
blob.contains("README.md"),
"missing per-file diff summary: {blob:?}"
saw_line1 && saw_line2,
"expected modal to show per-line diff summary"
);
}

View File

@@ -1,16 +1,20 @@
use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::exec_command::relativize_to_home;
use crate::history_cell::PatchEventType;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange;
@@ -23,24 +27,58 @@ enum DiffLineType {
Context,
}
pub struct DiffSummary {
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl DiffSummary {
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: PathBuf) -> Self {
Self { changes, cwd }
}
}
impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(self, &mut lines, width as usize);
lines.len() as u16
}
}
impl From<DiffSummary> for Box<dyn Renderable> {
fn from(val: DiffSummary) -> Self {
let mut rows: Vec<Box<dyn Renderable>> = vec![];
for (i, row) in collect_rows(&val.changes).into_iter().enumerate() {
if i > 0 {
rows.push(Box::new(RtLine::from("")));
}
let mut path = RtLine::from(display_path_for(&row.path, &val.cwd));
path.push_span(" ");
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(row.change));
}
Box::new(ColumnRenderable::new(rows))
}
}
pub(crate) fn create_diff_summary(
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
let rows = collect_rows(changes);
let header_kind = match event_type {
PatchEventType::ApplyBegin { auto_approved } => {
if auto_approved {
HeaderKind::Edited
} else {
HeaderKind::ChangeApproved
}
}
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
};
render_changes_block(rows, wrap_cols, header_kind, cwd)
render_changes_block(rows, wrap_cols, cwd)
}
// Shared row for per-file presentation
@@ -81,30 +119,18 @@ fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
rows
}
enum HeaderKind {
ProposedChange,
Edited,
ChangeApproved,
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
fn render_changes_block(
rows: Vec<Row>,
wrap_cols: usize,
header_kind: HeaderKind,
cwd: &Path,
) -> Vec<RtLine<'static>> {
fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols = wrap_cols;
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
@@ -121,66 +147,31 @@ fn render_changes_block(
let file_count = rows.len();
let noun = if file_count == 1 { "file" } else { "files" };
let mut header_spans: Vec<RtSpan<'static>> = vec!["".into()];
match header_kind {
HeaderKind::ProposedChange => {
header_spans.push("Proposed Change".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" to {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::Edited => {
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::ChangeApproved => {
header_spans.push("Change Approved".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
out.push(RtLine::from(header_spans));
// For Change Approved, we only show the header summary and no per-file/diff details.
if matches!(header_kind, HeaderKind::ChangeApproved) {
return out;
}
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
if idx > 0 {
out.push("".into());
}
// File header line (skip when single-file header already shows the name)
let skip_file_header =
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
&& file_count == 1;
let skip_file_header = file_count == 1;
if !skip_file_header {
let mut header: Vec<RtSpan<'static>> = Vec::new();
header.push("".dim());
@@ -190,71 +181,77 @@ fn render_changes_block(
out.push(RtLine::from(header));
}
match r.change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
term_cols,
));
}
}
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
term_cols,
));
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
render_change(&r.change, &mut out, wrap_cols);
}
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
term_cols,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
term_cols,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
term_cols,
));
old_ln += 1;
new_ln += 1;
}
out
}
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
match change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
));
}
}
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
));
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
));
old_ln += 1;
new_ln += 1;
}
}
}
@@ -262,8 +259,6 @@ fn render_changes_block(
}
}
}
out
}
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
@@ -274,7 +269,9 @@ pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
let chosen = if path_in_same_repo {
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
} else {
relativize_to_home(path).unwrap_or_else(|| path.to_path_buf())
relativize_to_home(path)
.map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()]))
.unwrap_or_else(|| path.to_path_buf())
};
chosen.display().to_string()
}
@@ -300,7 +297,7 @@ fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
text: &str,
term_cols: usize,
width: usize,
) -> Vec<RtLine<'static>> {
let indent = " ";
let ln_str = line_number.to_string();
@@ -325,7 +322,7 @@ fn push_wrapped_diff_line(
// Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1);
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let split_at_byte_index = remaining_text
.char_indices()
.nth(available_content_cols)
@@ -385,11 +382,8 @@ mod tests {
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn diff_summary_for_tests(
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
) -> Vec<RtLine<'static>> {
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
create_diff_summary(changes, &PathBuf::from("/"), 80)
}
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
@@ -421,42 +415,6 @@ mod tests {
assert_snapshot!(name, text);
}
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
content: "first line\nsecond line\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
#[test]
fn ui_snapshot_update_details_with_rename() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("src/lib.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("src/lib_new.rs")),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
#[test]
fn ui_snapshot_wrap_behavior_insert() {
// Narrow width to force wrapping within our diff line rendering
@@ -469,71 +427,6 @@ mod tests {
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
}
#[test]
fn ui_snapshot_single_line_replacement_counts() {
// Reproduce: one deleted line replaced by one inserted line, no extra context
let original = "# Codex CLI (Rust Implementation)\n";
let modified = "# Codex CLI (Rust Implementation) banana\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
}
#[test]
fn ui_snapshot_blank_context_line() {
// Ensure a hunk that includes a blank context line at the beginning is rendered visibly
let original = "\nY\n";
let modified = "\nY changed\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("blank_context_line", lines, 80, 10);
}
#[test]
fn ui_snapshot_vertical_ellipsis_between_hunks() {
// Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮)
let original =
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n";
let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
// Height is large enough to show both hunks and the separator
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
}
#[test]
fn ui_snapshot_apply_update_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
@@ -549,12 +442,8 @@ mod tests {
},
);
for (name, auto_approved) in [
("apply_update_block", true),
("apply_update_block_manual", false),
] {
let lines =
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
for name in ["apply_update_block", "apply_update_block_manual"] {
let lines = diff_summary_for_tests(&changes);
snapshot_lines(name, lines, 80, 12);
}
@@ -575,12 +464,7 @@ mod tests {
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
}
@@ -608,12 +492,7 @@ mod tests {
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
}
@@ -628,12 +507,7 @@ mod tests {
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
let lines = diff_summary_for_tests(&changes);
snapshot_lines("apply_add_block", lines, 80, 10);
}
@@ -652,12 +526,7 @@ mod tests {
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
let lines = diff_summary_for_tests(&changes);
// Cleanup best-effort; rendering has already read the file
let _ = std::fs::remove_file(&tmp_path);
@@ -681,14 +550,7 @@ mod tests {
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
72,
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
@@ -711,14 +573,7 @@ mod tests {
},
);
let mut lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
28,
);
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
// Drop the combined header for this text-only snapshot
if !lines.is_empty() {
lines.remove(0);
@@ -745,14 +600,7 @@ mod tests {
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&cwd,
80,
);
let lines = create_diff_summary(&changes, &cwd, 80);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}

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