Compare commits

...

15 Commits

Author SHA1 Message Date
Dylan Hurd
72d8bde988 [apply-patch] Handle multiple context lines 2025-08-25 15:09:37 -07:00
Dylan
7f7d1e30f3 [exec] Clean up apply-patch tests (#2648)
## Summary
These tests were getting a bit unwieldy, and they're starting to become
load-bearing. Let's clean them up, and get them working solidly so we
can easily expand this harness with new tests.

## Test Plan
- [x] Tests continue to pass
2025-08-25 15:08:01 -07:00
Michael Bolin
568d6f819f fix: use backslash as path separator on Windows (#2684)
I noticed that when running `/status` on Windows, I saw something like:

```
Path: ~/src\codex
```

so now it should be:

```
Path: ~\src\codex
```

Admittedly, `~` is understood by PowerShell but not on Windows, in
general, but it's much less verbose than `%USERPROFILE%`.
2025-08-25 14:47:17 -07:00
Jeremy Rose
251c4c2ba9 tui: queue messages (#2637)
https://github.com/user-attachments/assets/44349aa6-3b97-4029-99e1-5484e9a8775f
2025-08-25 21:38:38 +00:00
Odysseas Yiakoumis
a6c346b9e1 avoid error when /compact response has no token_usage (#2417) (#2640)
**Context**  
When running `/compact`, `drain_to_completed` would throw an error if
`token_usage` was `None` in `ResponseEvent::Completed`. This made the
command fail even though everything else had succeeded.

**What changed**  
- Instead of erroring, we now just check `if let Some(token_usage)`
before sending the event.
- If it’s missing, we skip it and move on.  

**Why**  
This makes `AgentTask::compact()` behave in the same way as
`AgentTask::spawn()`, which also doesn’t error out when `token_usage`
isn’t available. Keeps things consistent and avoids unnecessary
failures.

**Fixes**  
Closes #2417

---------

Co-authored-by: Ahmed Ibrahim <aibrahim@openai.com>
2025-08-25 18:42:22 +00:00
Gabriel Peal
e307040f10 Index file (#2678) 2025-08-25 13:23:32 -04:00
dependabot[bot]
7d67e54628 chore(deps): bump toml_edit from 0.23.3 to 0.23.4 in /codex-rs (#2665) 2025-08-25 08:20:30 -07:00
Michael Bolin
295ca27e98 fix: Scope ExecSessionManager to Session instead of using global singleton (#2664)
The `SessionManager` in `exec_command` owns a number of
`ExecCommandSession` objects where `ExecCommandSession` has a
non-trivial implementation of `Drop`, so we want to be able to drop an
individual `SessionManager` to help ensure things get cleaned up in a
timely fashion. To that end, we should have one `SessionManager` per
session rather than one global one for the lifetime of the CLI process.
2025-08-24 22:52:49 -07:00
Michael Bolin
7b20db942a fix: build is broken on main; introduce ToolsConfigParams to help fix (#2663)
`ToolsConfig::new()` taking a large number of boolean params was hard to
manage and it finally bit us (see
https://github.com/openai/codex/pull/2660). This changes
`ToolsConfig::new()` so that it takes a struct (and also reduces the
visibility of some members, where possible).
2025-08-24 22:43:42 -07:00
Uhyeon Park
ee2ccb5cb6 Fix cache hit rate by making MCP tools order deterministic (#2611)
Fixes https://github.com/openai/codex/issues/2610

This PR sorts the tools in `get_openai_tools` by name to ensure a
consistent MCP tool order.

Currently, MCP servers are stored in a HashMap, which does not guarantee
ordering. As a result, the tool order changes across turns, effectively
breaking prompt caching in multi-turn sessions.

An alternative solution would be to replace the HashMap with an ordered
structure, but that would require a much larger code change. Given that
it is unrealistic to have so many MCP tools that sorting would cause
performance issues, this lightweight fix is chosen instead.

By ensuring deterministic tool order, this change should significantly
improve cache hit rates and prevent users from hitting usage limits too
quickly. (For reference, my own sessions last week reached the limit
unusually fast, with cache hit rates falling below 1%.)

## Result

After this fix, sessions with MCP servers now show caching behavior
almost identical to sessions without MCP servers.
Without MCP             |  With MCP
:-------------------------:|:-------------------------:
<img width="1368" height="1634" alt="image"
src="https://github.com/user-attachments/assets/26edab45-7be8-4d6a-b471-558016615fc8"
/> | <img width="1356" height="1632" alt="image"
src="https://github.com/user-attachments/assets/5f3634e0-3888-420b-9aaf-deefd9397b40"
/>
2025-08-24 19:56:24 -07:00
ae
8b49346657 fix: update gpt-5 stats (#2649)
- To match what's on <https://platform.openai.com/docs/models/gpt-5>.
2025-08-24 16:45:41 -07:00
dependabot[bot]
e49116a4c5 chore(deps): bump whoami from 1.6.0 to 1.6.1 in /codex-rs (#2497)
Bumps [whoami](https://github.com/ardaku/whoami) from 1.6.0 to 1.6.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ardaku/whoami/commits">compare view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-24 14:38:30 -07:00
Michael Bolin
517ffd00c6 feat: use the arg0 trick with apply_patch (#2646)
Historically, Codex CLI has treated `apply_patch` (and its sometimes
misspelling, `applypatch`) as a "virtual CLI," intercepting it when it
appears as the first arg to `command` for the `"container.exec",
`"shell"`, or `"local_shell"` tools.

This approach has a known limitation where if, say, the model created a
Python script that runs `apply_patch` and then tried to run the Python
script, we have no insight as to what the model is trying to do and the
Python Script would fail because `apply_patch` was never really on the
`PATH`.

One way to solve this problem is to require users to install an
`apply_patch` executable alongside the `codex` executable (or at least
put it someplace where Codex can discover it). Though to keep Codex CLI
as a standalone executable, we exploit "the arg0 trick" where we create
a temporary directory with an entry named `apply_patch` and prepend that
directory to the `PATH` for the duration of the invocation of Codex.

- On UNIX, `apply_patch` is a symlink to `codex`, which now changes its
behavior to behave like `apply_patch` if arg0 is `apply_patch` (or
`applypatch`)
- On Windows, `apply_patch.bat` is a batch script that runs `codex
--codex-run-as-apply-patch %*`, as Codex also changes its behavior if
the first argument is `--codex-run-as-apply-patch`.
2025-08-24 14:35:51 -07:00
Dylan
4157788310 [apply_patch] disable default freeform tool (#2643)
## Summary
We're seeing some issues in the freeform tool - let's disable by default
until it stabilizes.

## Testing
- [x] Ran locally, confirmed codex-cli could make edits
2025-08-24 11:12:37 -07:00
Jeremy Rose
32bbbbad61 test: faster test execution in codex-core (#2633)
this dramatically improves time to run `cargo test -p codex-core` (~25x
speedup).

before:
```
cargo test -p codex-core  35.96s user 68.63s system 19% cpu 8:49.80 total
```

after:
```
cargo test -p codex-core  5.51s user 8.16s system 63% cpu 21.407 total
```

both tests measured "hot", i.e. on a 2nd run with no filesystem changes,
to exclude compile times.

approach inspired by [Delete Cargo Integration
Tests](https://matklad.github.io/2021/02/27/delete-cargo-integration-tests.html),
we move all test cases in tests/ into a single suite in order to have a
single binary, as there is significant overhead for each test binary
executed, and because test execution is only parallelized with a single
binary.
2025-08-24 11:10:53 -07:00
105 changed files with 1785 additions and 932 deletions

15
codex-rs/Cargo.lock generated
View File

@@ -635,6 +635,7 @@ name = "codex-apply-patch"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"pretty_assertions",
"similar",
"tempfile",
@@ -652,6 +653,7 @@ dependencies = [
"codex-core",
"codex-linux-sandbox",
"dotenvy",
"tempfile",
"tokio",
]
@@ -752,7 +754,7 @@ dependencies = [
"tokio-test",
"tokio-util",
"toml 0.9.5",
"toml_edit 0.23.3",
"toml_edit 0.23.4",
"tracing",
"tree-sitter",
"tree-sitter-bash",
@@ -2720,6 +2722,7 @@ checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall",
]
[[package]]
@@ -5192,9 +5195,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.23.3"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e"
checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93"
dependencies = [
"indexmap 2.10.0",
"toml_datetime 0.7.0",
@@ -5775,11 +5778,11 @@ dependencies = [
[[package]]
name = "whoami"
version = "1.6.0"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"redox_syscall",
"libredox",
"wasite",
"web-sys",
]

View File

@@ -7,6 +7,10 @@ version = { workspace = true }
name = "codex_apply_patch"
path = "src/lib.rs"
[[bin]]
name = "apply_patch"
path = "src/main.rs"
[lints]
workspace = true
@@ -18,5 +22,6 @@ tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
[dev-dependencies]
assert_cmd = "2"
pretty_assertions = "1.4.1"
tempfile = "3.13.0"

View File

@@ -1,5 +1,6 @@
mod parser;
mod seek_sequence;
mod standalone_executable;
use std::collections::HashMap;
use std::path::Path;
@@ -19,6 +20,8 @@ use tree_sitter::LanguageError;
use tree_sitter::Parser;
use tree_sitter_bash::LANGUAGE as BASH;
pub use standalone_executable::main;
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
@@ -529,32 +532,51 @@ fn compute_replacements(
let mut line_index: usize = 0;
for chunk in chunks {
// If a chunk has a `change_context`, we use seek_sequence to find it, then
// adjust our `line_index` to continue from there.
if let Some(ctx_line) = &chunk.change_context {
if let Some(idx) = seek_sequence::seek_sequence(
original_lines,
std::slice::from_ref(ctx_line),
line_index,
false,
) {
line_index = idx + 1;
} else {
return Err(ApplyPatchError::ComputeReplacements(format!(
"Failed to find context '{}' in {}",
ctx_line,
path.display()
)));
// If a chunk has context lines, we use seek_sequence to find each in order,
// then adjust our `line_index` to continue from there.
if !chunk.context_lines.is_empty() {
let total = chunk.context_lines.len();
for (i, ctx_line) in chunk.context_lines.iter().enumerate() {
if let Some(idx) = seek_sequence::seek_sequence(
original_lines,
std::slice::from_ref(ctx_line),
line_index,
false,
) {
line_index = idx + 1;
} else {
return Err(ApplyPatchError::ComputeReplacements(format!(
"Failed to find context {}/{}: '{}' in {}",
i + 1,
total,
ctx_line,
path.display()
)));
}
}
}
if chunk.old_lines.is_empty() {
// Pure addition (no old lines). We'll add them at the end or just
// before the final empty line if one exists.
let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
original_lines.len() - 1
// Pure addition (no old lines).
// Prefer to insert at the matched context anchor if one exists and
// the hunk is not explicitly marked as end-of-file.
let insertion_idx = if chunk.is_end_of_file {
if original_lines.last().is_some_and(|s| s.is_empty()) {
original_lines.len() - 1
} else {
original_lines.len()
}
} else if !chunk.context_lines.is_empty() {
// Insert immediately after the last matched context line.
line_index
} else {
original_lines.len()
// No context provided: fall back to appending at the end (before
// the trailing empty line if present).
if original_lines.last().is_some_and(|s| s.is_empty()) {
original_lines.len() - 1
} else {
original_lines.len()
}
};
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
continue;
@@ -1267,6 +1289,57 @@ g
);
}
#[test]
fn test_insert_addition_after_single_context_anchor() {
let dir = tempdir().unwrap();
let path = dir.path().join("single_ctx.txt");
fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@ class BaseClass:
+INSERTED
"#,
path.display()
));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
let contents = fs::read_to_string(path).unwrap();
assert_eq!(
contents,
"class BaseClass:\nINSERTED\n def method():\nline1\nline2\n"
);
}
#[test]
fn test_insert_addition_after_multi_context_anchor() {
let dir = tempdir().unwrap();
let path = dir.path().join("multi_ctx.txt");
fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@ class BaseClass:
@@ def method():
+INSERTED
"#,
path.display()
));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
let contents = fs::read_to_string(path).unwrap();
assert_eq!(
contents,
"class BaseClass:\n def method():\nINSERTED\nline1\nline2\n"
);
}
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();

View File

@@ -0,0 +1,3 @@
pub fn main() -> ! {
codex_apply_patch::main()
}

View File

@@ -69,7 +69,7 @@ pub enum Hunk {
path: PathBuf,
move_path: Option<PathBuf>,
/// Chunks should be in order, i.e. the `change_context` of one chunk
/// Chunks should be in order, i.e. the first context line of one chunk
/// should occur later in the file than the previous chunk.
chunks: Vec<UpdateFileChunk>,
},
@@ -89,12 +89,13 @@ use Hunk::*;
#[derive(Debug, PartialEq, Clone)]
pub struct UpdateFileChunk {
/// A single line of context used to narrow down the position of the chunk
/// (this is usually a class, method, or function definition.)
pub change_context: Option<String>,
/// Context lines used to narrow down the position of the chunk.
/// Each entry is searched sequentially to progressively restrict the
/// search to the desired region (e.g. class → method).
pub context_lines: Vec<String>,
/// A contiguous block of lines that should be replaced with `new_lines`.
/// `old_lines` must occur strictly after `change_context`.
/// `old_lines` must occur strictly after the context.
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
@@ -344,32 +345,38 @@ fn parse_update_file_chunk(
line_number,
});
}
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
// allow treating the chunk as starting directly with diff lines.
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
(None, 1)
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
(Some(context.to_string()), 1)
} else {
if !allow_missing_context {
return Err(InvalidHunkError {
message: format!(
"Expected update hunk to start with a @@ context marker, got: '{}'",
lines[0]
),
line_number,
});
let mut context_lines = Vec::new();
let mut start_index = 0;
let mut saw_context_marker = false;
while start_index < lines.len() {
if lines[start_index] == EMPTY_CHANGE_CONTEXT_MARKER {
saw_context_marker = true;
start_index += 1;
} else if let Some(context) = lines[start_index].strip_prefix(CHANGE_CONTEXT_MARKER) {
saw_context_marker = true;
context_lines.push(context.to_string());
start_index += 1;
} else {
break;
}
(None, 0)
};
}
if !saw_context_marker && !allow_missing_context {
return Err(InvalidHunkError {
message: format!(
"Expected update hunk to start with a @@ context marker, got: '{}'",
lines[0]
),
line_number,
});
}
if start_index >= lines.len() {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: line_number + 1,
line_number: line_number + start_index,
});
}
let mut chunk = UpdateFileChunk {
change_context,
context_lines,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
@@ -381,7 +388,7 @@ fn parse_update_file_chunk(
if parsed_lines == 0 {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: line_number + 1,
line_number: line_number + start_index,
});
}
chunk.is_end_of_file = true;
@@ -411,7 +418,7 @@ fn parse_update_file_chunk(
message: format!(
"Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
),
line_number: line_number + 1,
line_number: line_number + start_index,
});
}
// Assume this is the start of the next hunk.
@@ -491,7 +498,7 @@ fn test_parse_patch() {
path: PathBuf::from("path/update.py"),
move_path: Some(PathBuf::from("path/update2.py")),
chunks: vec![UpdateFileChunk {
change_context: Some("def f():".to_string()),
context_lines: vec!["def f():".to_string()],
old_lines: vec![" pass".to_string()],
new_lines: vec![" return 123".to_string()],
is_end_of_file: false
@@ -518,7 +525,7 @@ fn test_parse_patch() {
path: PathBuf::from("file.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
context_lines: Vec::new(),
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: false
@@ -548,7 +555,7 @@ fn test_parse_patch() {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
context_lines: Vec::new(),
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
@@ -568,7 +575,7 @@ fn test_parse_patch_lenient() {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
context_lines: Vec::new(),
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
@@ -701,7 +708,7 @@ fn test_update_file_chunk() {
),
Ok((
(UpdateFileChunk {
change_context: Some("change_context".to_string()),
context_lines: vec!["change_context".to_string()],
old_lines: vec![
"".to_string(),
"context".to_string(),
@@ -723,7 +730,7 @@ fn test_update_file_chunk() {
parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
Ok((
(UpdateFileChunk {
change_context: None,
context_lines: Vec::new(),
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: true
@@ -731,4 +738,29 @@ fn test_update_file_chunk() {
3
))
);
assert_eq!(
parse_update_file_chunk(
&[
"@@ class BaseClass",
"@@ def method()",
" context",
"-old",
"+new",
],
123,
false
),
Ok((
(UpdateFileChunk {
context_lines: vec![
"class BaseClass".to_string(),
" def method()".to_string()
],
old_lines: vec!["context".to_string(), "old".to_string()],
new_lines: vec!["context".to_string(), "new".to_string()],
is_end_of_file: false
}),
5
))
);
}

View File

@@ -0,0 +1,59 @@
use std::io::Read;
use std::io::Write;
pub fn main() -> ! {
let exit_code = run_main();
std::process::exit(exit_code);
}
/// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
/// method is still a nightly API and we want main() to return !.
pub fn run_main() -> i32 {
// Expect either one argument (the full apply_patch payload) or read it from stdin.
let mut args = std::env::args_os();
let _argv0 = args.next();
let patch_arg = match args.next() {
Some(arg) => match arg.into_string() {
Ok(s) => s,
Err(_) => {
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
return 1;
}
},
None => {
// No argument provided; attempt to read the patch from stdin.
let mut buf = String::new();
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => {
if buf.is_empty() {
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply-patch");
return 2;
}
buf
}
Err(err) => {
eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
return 1;
}
}
}
};
// Refuse extra args to avoid ambiguity.
if args.next().is_some() {
eprintln!("Error: apply_patch accepts exactly one argument.");
return 2;
}
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
let _ = stdout.flush();
0
}
Err(_) => 1,
}
}

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,90 @@
use assert_cmd::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
#[test]
fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "cli_test.txt";
let absolute_path = tmp.path().join(file);
// 1) Add a file
let add_patch = format!(
r#"*** Begin Patch
*** Add File: {file}
+hello
*** End Patch"#
);
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(add_patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
// 2) Update the file
let update_patch = format!(
r#"*** Begin Patch
*** Update File: {file}
@@
-hello
+world
*** End Patch"#
);
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(update_patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
Ok(())
}
#[test]
fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "cli_test_stdin.txt";
let absolute_path = tmp.path().join(file);
// 1) Add a file via stdin
let add_patch = format!(
r#"*** Begin Patch
*** Add File: {file}
+hello
*** End Patch"#
);
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(add_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
// 2) Update the file via stdin
let update_patch = format!(
r#"*** Begin Patch
*** Update File: {file}
@@
-hello
+world
*** End Patch"#
);
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(update_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
Ok(())
}

View File

@@ -0,0 +1 @@
mod cli;

View File

@@ -16,4 +16,5 @@ codex-apply-patch = { path = "../apply-patch" }
codex-core = { path = "../core" }
codex-linux-sandbox = { path = "../linux-sandbox" }
dotenvy = "0.15.7"
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread"] }

View File

@@ -3,6 +3,13 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::CODEX_APPLY_PATCH_ARG1;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::TempDir;
const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
@@ -39,9 +46,11 @@ where
.and_then(|s| s.to_str())
.unwrap_or("");
if exe_name == "codex-linux-sandbox" {
if exe_name == LINUX_SANDBOX_ARG0 {
// Safety: [`run_main`] never returns.
codex_linux_sandbox::run_main();
} else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 {
codex_apply_patch::main();
}
let argv1 = args.next().unwrap_or_default();
@@ -68,6 +77,19 @@ where
// before creating any threads/the Tokio runtime.
load_dotenv();
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
let _path_entry = match prepend_path_entry_for_apply_patch() {
Ok(path_entry) => Some(path_entry),
Err(err) => {
// It is possible that Codex will proceed successfully even if
// updating the PATH fails, so warn the user and move on.
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
None
}
};
// Regular invocation create a Tokio runtime and execute the provided
// async entry-point.
let runtime = tokio::runtime::Runtime::new()?;
@@ -113,3 +135,67 @@ where
}
}
}
/// Creates a temporary directory with either:
///
/// - UNIX: `apply_patch` symlink to the current executable
/// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable
/// with the "secret" --codex-run-as-apply-patch flag.
///
/// This temporary directory is prepended to the PATH environment variable so
/// that `apply_patch` can be on the PATH without requiring the user to
/// install a separate `apply_patch` executable, simplifying the deployment of
/// Codex CLI.
///
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
/// be called before multiple threads are spawned.
fn prepend_path_entry_for_apply_patch() -> std::io::Result<TempDir> {
let temp_dir = TempDir::new()?;
let path = temp_dir.path();
for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] {
let exe = std::env::current_exe()?;
#[cfg(unix)]
{
let link = path.join(filename);
symlink(&exe, &link)?;
}
#[cfg(windows)]
{
let batch_script = path.join(format!("{filename}.bat"));
std::fs::write(
&batch_script,
format!(
r#"@echo off
"{}" {CODEX_APPLY_PATCH_ARG1} %*
"#,
exe.display()
),
)?;
}
}
#[cfg(unix)]
const PATH_SEPARATOR: &str = ":";
#[cfg(windows)]
const PATH_SEPARATOR: &str = ";";
let path_element = path.display();
let updated_path_env_var = match std::env::var("PATH") {
Ok(existing_path) => {
format!("{path_element}{PATH_SEPARATOR}{existing_path}")
}
Err(_) => {
format!("{path_element}")
}
};
unsafe {
std::env::set_var("PATH", updated_path_env_var);
}
Ok(temp_dir)
}

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,2 @@
// Aggregates all former standalone integration tests as modules.
mod apply_command_e2e;

View File

@@ -6,6 +6,7 @@ version = { workspace = true }
[lib]
name = "codex_core"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
@@ -51,12 +52,12 @@ tokio = { version = "1", features = [
] }
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.3"
toml_edit = "0.23.4"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
whoami = "1.6.0"
whoami = "1.6.1"
wildmatch = "2.4.0"

View File

@@ -55,7 +55,7 @@ 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::SESSION_MANAGER;
use crate::exec_command::ExecSessionManager;
use crate::exec_command::WRITE_STDIN_TOOL_NAME;
use crate::exec_command::WriteStdinParams;
use crate::exec_env::create_env;
@@ -64,6 +64,7 @@ use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::model_family::find_family_for_model;
use crate::openai_tools::ApplyPatchToolArgs;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::ToolsConfigParams;
use crate::openai_tools::get_openai_tools;
use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
@@ -268,6 +269,7 @@ pub(crate) struct Session {
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
session_manager: ExecSessionManager,
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
@@ -506,15 +508,15 @@ impl Session {
);
let turn_context = TurnContext {
client,
tools_config: ToolsConfig::new(
&config.model_family,
tools_config: ToolsConfig::new(&ToolsConfigParams {
model_family: &config.model_family,
approval_policy,
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.tools_web_search_request,
config.use_experimental_streamable_shell_tool,
),
sandbox_policy: sandbox_policy.clone(),
include_plan_tool: config.include_plan_tool,
include_apply_patch_tool: config.include_apply_patch_tool,
include_web_search_request: config.tools_web_search_request,
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
}),
user_instructions,
base_instructions,
approval_policy,
@@ -527,6 +529,7 @@ impl Session {
session_id,
tx_event: tx_event.clone(),
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
notify,
state: Mutex::new(state),
rollout: Mutex::new(rollout_recorder),
@@ -1092,15 +1095,15 @@ async fn submission_loop(
.unwrap_or(prev.sandbox_policy.clone());
let new_cwd = cwd.clone().unwrap_or_else(|| prev.cwd.clone());
let tools_config = ToolsConfig::new(
&effective_family,
new_approval_policy,
new_sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.tools_web_search_request,
config.use_experimental_streamable_shell_tool,
);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &effective_family,
approval_policy: new_approval_policy,
sandbox_policy: new_sandbox_policy.clone(),
include_plan_tool: config.include_plan_tool,
include_apply_patch_tool: config.include_apply_patch_tool,
include_web_search_request: config.tools_web_search_request,
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
});
let new_turn_context = TurnContext {
client,
@@ -1172,15 +1175,16 @@ async fn submission_loop(
let fresh_turn_context = TurnContext {
client,
tools_config: ToolsConfig::new(
&model_family,
tools_config: ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy,
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.tools_web_search_request,
config.use_experimental_streamable_shell_tool,
),
sandbox_policy: sandbox_policy.clone(),
include_plan_tool: config.include_plan_tool,
include_apply_patch_tool: config.include_apply_patch_tool,
include_web_search_request: config.tools_web_search_request,
use_streamable_shell_tool: config
.use_experimental_streamable_shell_tool,
}),
user_instructions: turn_context.user_instructions.clone(),
base_instructions: turn_context.base_instructions.clone(),
approval_policy,
@@ -2110,7 +2114,8 @@ async fn handle_function_call(
};
}
};
let result = SESSION_MANAGER
let result = sess
.session_manager
.handle_exec_command_request(exec_params)
.await;
let function_call_output = crate::exec_command::result_into_payload(result);
@@ -2132,7 +2137,8 @@ async fn handle_function_call(
};
}
};
let result = SESSION_MANAGER
let result = sess
.session_manager
.handle_write_stdin_request(write_stdin_params)
.await;
let function_call_output: FunctionCallOutputPayload =
@@ -2810,15 +2816,9 @@ async fn drain_to_completed(
response_id: _,
token_usage,
}) => {
let token_usage = match token_usage {
Some(usage) => usage,
None => {
return Err(CodexErr::Stream(
"token_usage was None in ResponseEvent::Completed".into(),
None,
));
}
};
// some providers don't return token usage, so we default
// TODO: consider approximate token usage
let token_usage = token_usage.unwrap_or_default();
sess.tx_event
.send(Event {
id: sub_id.to_string(),
@@ -2826,6 +2826,7 @@ async fn drain_to_completed(
})
.await
.ok();
return Ok(());
}
Ok(_) => continue,

View File

@@ -10,5 +10,5 @@ pub use responses_api::EXEC_COMMAND_TOOL_NAME;
pub use responses_api::WRITE_STDIN_TOOL_NAME;
pub use responses_api::create_exec_command_tool_for_responses_api;
pub use responses_api::create_write_stdin_tool_for_responses_api;
pub use session_manager::SESSION_MANAGER;
pub use session_manager::SessionManager as ExecSessionManager;
pub use session_manager::result_into_payload;

View File

@@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::io::ErrorKind;
use std::io::Read;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicU32;
@@ -22,8 +21,6 @@ use crate::exec_command::exec_command_session::ExecCommandSession;
use crate::exec_command::session_id::SessionId;
use codex_protocol::models::FunctionCallOutputPayload;
pub static SESSION_MANAGER: LazyLock<SessionManager> = LazyLock::new(SessionManager::default);
#[derive(Debug, Default)]
pub struct SessionManager {
next_session_id: AtomicU32,

View File

@@ -90,7 +90,6 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else if slug.starts_with("gpt-4.1") {
model_family!(
@@ -107,7 +106,6 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, "gpt-5",
supports_reasoning_summaries: true,
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else {
None

View File

@@ -79,13 +79,13 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
}),
"gpt-5" => Some(ModelInfo {
context_window: 200_000,
max_output_tokens: 100_000,
context_window: 400_000,
max_output_tokens: 128_000,
}),
_ if slug.starts_with("codex-") => Some(ModelInfo {
context_window: 200_000,
max_output_tokens: 100_000,
context_window: 400_000,
max_output_tokens: 128_000,
}),
_ => None,

View File

@@ -62,24 +62,35 @@ pub enum ConfigShellToolType {
}
#[derive(Debug, Clone)]
pub struct ToolsConfig {
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub plan_tool: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
}
pub(crate) struct ToolsConfigParams<'a> {
pub(crate) model_family: &'a ModelFamily,
pub(crate) approval_policy: AskForApproval,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) include_plan_tool: bool,
pub(crate) include_apply_patch_tool: bool,
pub(crate) include_web_search_request: bool,
pub(crate) use_streamable_shell_tool: bool,
}
impl ToolsConfig {
pub fn new(
model_family: &ModelFamily,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
include_plan_tool: bool,
include_apply_patch_tool: bool,
include_web_search_request: bool,
use_streamable_shell_tool: bool,
) -> Self {
let mut shell_type = if use_streamable_shell_tool {
pub fn new(params: &ToolsConfigParams) -> Self {
let ToolsConfigParams {
model_family,
approval_policy,
sandbox_policy,
include_plan_tool,
include_apply_patch_tool,
include_web_search_request,
use_streamable_shell_tool,
} = params;
let mut shell_type = if *use_streamable_shell_tool {
ConfigShellToolType::StreamableShell
} else if model_family.uses_local_shell_tool {
ConfigShellToolType::LocalShell
@@ -96,7 +107,7 @@ impl ToolsConfig {
Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
None => {
if include_apply_patch_tool {
if *include_apply_patch_tool {
Some(ApplyPatchToolType::Freeform)
} else {
None
@@ -106,9 +117,9 @@ impl ToolsConfig {
Self {
shell_type,
plan_tool: include_plan_tool,
plan_tool: *include_plan_tool,
apply_patch_tool_type,
web_search_request: include_web_search_request,
web_search_request: *include_web_search_request,
}
}
}
@@ -531,7 +542,12 @@ pub(crate) fn get_openai_tools(
}
if let Some(mcp_tools) = mcp_tools {
for (name, tool) in mcp_tools {
// Ensure deterministic ordering to maximize prompt cache hits.
// HashMap iteration order is non-deterministic, so sort by fully-qualified tool name.
let mut entries: Vec<(String, mcp_types::Tool)> = mcp_tools.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (name, tool) in entries.into_iter() {
match mcp_tool_to_openai_tool(name.clone(), tool.clone()) {
Ok(converted_tool) => tools.push(OpenAiTool::Function(converted_tool)),
Err(e) => {
@@ -580,15 +596,15 @@ mod tests {
fn test_get_openai_tools() {
let model_family = find_family_for_model("codex-mini-latest")
.expect("codex-mini-latest should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: true,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(&config, Some(HashMap::new()));
assert_eq_tool_names(&tools, &["local_shell", "update_plan", "web_search"]);
@@ -597,15 +613,15 @@ mod tests {
#[test]
fn test_get_openai_tools_default_shell() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: true,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(&config, Some(HashMap::new()));
assert_eq_tool_names(&tools, &["shell", "update_plan", "web_search"]);
@@ -614,15 +630,15 @@ mod tests {
#[test]
fn test_get_openai_tools_mcp_tools() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
@@ -710,18 +726,93 @@ mod tests {
);
}
#[test]
fn test_get_openai_tools_mcp_tools_sorted_by_name() {
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,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: false,
use_streamable_shell_tool: false,
});
// Intentionally construct a map with keys that would sort alphabetically.
let tools_map: HashMap<String, mcp_types::Tool> = HashMap::from([
(
"test_server/do".to_string(),
mcp_types::Tool {
name: "a".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("a".to_string()),
},
),
(
"test_server/something".to_string(),
mcp_types::Tool {
name: "b".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("b".to_string()),
},
),
(
"test_server/cool".to_string(),
mcp_types::Tool {
name: "c".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("c".to_string()),
},
),
]);
let tools = get_openai_tools(&config, Some(tools_map));
// Expect shell first, followed by MCP tools sorted by fully-qualified name.
assert_eq_tool_names(
&tools,
&[
"shell",
"test_server/cool",
"test_server/do",
"test_server/something",
],
);
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(
&config,
@@ -771,15 +862,15 @@ mod tests {
#[test]
fn test_mcp_tool_integer_normalized_to_number() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(
&config,
@@ -824,15 +915,15 @@ mod tests {
#[test]
fn test_mcp_tool_array_without_items_gets_default_string_items() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(
&config,
@@ -880,15 +971,15 @@ mod tests {
#[test]
fn test_mcp_tool_anyof_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
false,
true,
/*use_experimental_streamable_shell_tool*/ false,
);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
});
let tools = get_openai_tools(
&config,

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/all/`.
mod suite;

View File

@@ -0,0 +1,12 @@
// Aggregates all former standalone integration tests as modules.
mod cli_stream;
mod client;
mod compact;
mod exec;
mod exec_stream_events;
mod live_cli;
mod prompt_caching;
mod seatbelt;
mod stream_error_allows_next_turn;
mod stream_no_completed;

View File

@@ -107,8 +107,8 @@ async fn codex_mini_latest_tools() {
assert_eq!(requests.len(), 2, "expected two POST requests");
let expected_instructions = [
include_str!("../prompt.md"),
include_str!("../../apply-patch/apply_patch_tool_instructions.md"),
include_str!("../../prompt.md"),
include_str!("../../../apply-patch/apply_patch_tool_instructions.md"),
]
.join("\n");
@@ -188,7 +188,7 @@ async fn prompt_tools_are_consistent_across_requests() {
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "expected two POST requests");
let expected_instructions: &str = include_str!("../prompt.md");
let expected_instructions: &str = include_str!("../../prompt.md");
// our internal implementation is responsible for keeping tools in sync
// with the OpenAI schema, so we just verify the tool presence here
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch"];

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -1,339 +0,0 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_core::CODEX_APPLY_PATCH_ARG1;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
/// While we may add an `apply-patch` subcommand to the `codex` CLI multitool
/// at some point, we must ensure that the smaller `codex-exec` CLI can still
/// emulate the `apply_patch` CLI.
#[test]
fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> {
let tmp = tempdir()?;
let relative_path = "source.txt";
let absolute_path = tmp.path().join(relative_path);
fs::write(&absolute_path, "original content\n")?;
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.arg(CODEX_APPLY_PATCH_ARG1)
.arg(
r#"*** Begin Patch
*** Update File: source.txt
@@
-original content
+modified by apply_patch
*** End Patch"#,
)
.current_dir(tmp.path())
.assert()
.success()
.stdout("Success. Updated the following files:\nM source.txt\n")
.stderr(predicates::str::is_empty());
assert_eq!(
fs::read_to_string(absolute_path)?,
"modified by apply_patch\n"
);
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn test_apply_patch_tool() -> anyhow::Result<()> {
use core_test_support::load_sse_fixture_with_id_from_str;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const SSE_TOOL_CALL_ADD: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"name": "apply_patch",
"arguments": "{\n \"input\": \"*** Begin Patch\\n*** Add File: test.md\\n+Hello world\\n*** End Patch\"\n}",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_UPDATE: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"name": "apply_patch",
"arguments": "{\n \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_COMPLETED: &str = r#"[
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
// Start a mock model server
let server = MockServer::start().await;
// First response: model calls apply_patch to create test.md
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
"text/event-stream",
);
Mock::given(method("POST"))
// .and(path("/v1/responses"))
.respond_with(first)
.up_to_n_times(1)
.mount(&server)
.await;
// Second response: model calls apply_patch to update test.md
let second = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(second)
.up_to_n_times(1)
.mount(&server)
.await;
let final_completed = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(final_completed)
.expect(1)
.mount(&server)
.await;
let tmp_cwd = TempDir::new().unwrap();
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.current_dir(tmp_cwd.path())
.env("CODEX_HOME", tmp_cwd.path())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
.arg("--skip-git-repo-check")
.arg("-s")
.arg("workspace-write")
.arg("foo")
.assert()
.success();
// Verify final file contents
let final_path = tmp_cwd.path().join("test.md");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(contents, "Final text\n");
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
use core_test_support::load_sse_fixture_with_id_from_str;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const SSE_TOOL_CALL_ADD: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_UPDATE: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Update File: test.md\n@@\n-Hello world\n+Final text\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_COMPLETED: &str = r#"[
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
// Start a mock model server
let server = MockServer::start().await;
// First response: model calls apply_patch to create test.md
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
"text/event-stream",
);
Mock::given(method("POST"))
// .and(path("/v1/responses"))
.respond_with(first)
.up_to_n_times(1)
.mount(&server)
.await;
// Second response: model calls apply_patch to update test.md
let second = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(second)
.up_to_n_times(1)
.mount(&server)
.await;
let final_completed = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
"text/event-stream",
);
Mock::given(method("POST"))
// .and(path("/v1/responses"))
.respond_with(final_completed)
.expect(1)
.mount(&server)
.await;
let tmp_cwd = TempDir::new().unwrap();
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.current_dir(tmp_cwd.path())
.env("CODEX_HOME", tmp_cwd.path())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
.arg("--skip-git-repo-check")
.arg("-s")
.arg("workspace-write")
.arg("foo")
.assert()
.success();
// Verify final file contents
let final_path = tmp_cwd.path().join("test.md");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(contents, "Final text\n");
Ok(())
}

View File

@@ -0,0 +1,4 @@
class BaseClass:
def method():
return True

View File

@@ -0,0 +1,25 @@
[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,25 @@
[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Update File: app.py\n@@ class BaseClass:\n@@ def method():\n- return False\n+ return True\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,25 @@
[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Add File: app.py\n+class BaseClass:\n+ def method():\n+ return False\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,25 @@
[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Update File: app.py\n@@ def method():\n- return False\n+\n+ return True\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,25 @@
[
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"name": "apply_patch",
"arguments": "{\n \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,16 @@
[
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]

View File

@@ -0,0 +1,146 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_core::CODEX_APPLY_PATCH_ARG1;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
/// While we may add an `apply-patch` subcommand to the `codex` CLI multitool
/// at some point, we must ensure that the smaller `codex-exec` CLI can still
/// emulate the `apply_patch` CLI.
#[test]
fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> {
let tmp = tempdir()?;
let relative_path = "source.txt";
let absolute_path = tmp.path().join(relative_path);
fs::write(&absolute_path, "original content\n")?;
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.arg(CODEX_APPLY_PATCH_ARG1)
.arg(
r#"*** Begin Patch
*** Update File: source.txt
@@
-original content
+modified by apply_patch
*** End Patch"#,
)
.current_dir(tmp.path())
.assert()
.success()
.stdout("Success. Updated the following files:\nM source.txt\n")
.stderr(predicates::str::is_empty());
assert_eq!(
fs::read_to_string(absolute_path)?,
"modified by apply_patch\n"
);
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_apply_patch_tool() -> anyhow::Result<()> {
use crate::suite::common::run_e2e_exec_test;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
if std::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 Ok(());
}
let tmp_cwd = tempdir().expect("failed to create temp dir");
let tmp_path = tmp_cwd.path().to_path_buf();
run_e2e_exec_test(
tmp_cwd.path(),
vec![
include_str!("../fixtures/sse_apply_patch_add.json").to_string(),
include_str!("../fixtures/sse_apply_patch_update.json").to_string(),
include_str!("../fixtures/sse_response_completed.json").to_string(),
],
)
.await;
let final_path = tmp_path.join("test.md");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(contents, "Final text\n");
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
use crate::suite::common::run_e2e_exec_test;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
if std::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 Ok(());
}
let tmp_cwd = tempdir().expect("failed to create temp dir");
run_e2e_exec_test(
tmp_cwd.path(),
vec![
include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(),
include_str!("../fixtures/sse_apply_patch_freeform_update.json").to_string(),
include_str!("../fixtures/sse_response_completed.json").to_string(),
],
)
.await;
// Verify final file contents
let final_path = tmp_cwd.path().join("app.py");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(
contents,
include_str!("../fixtures/apply_patch_freeform_final.txt")
);
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn test_apply_patch_context() -> anyhow::Result<()> {
use crate::suite::common::run_e2e_exec_test;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
if std::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 Ok(());
}
let tmp_cwd = tempdir().expect("failed to create temp dir");
run_e2e_exec_test(
tmp_cwd.path(),
vec![
include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(),
include_str!("../fixtures/sse_apply_patch_context_update.json").to_string(),
include_str!("../fixtures/sse_response_completed.json").to_string(),
],
)
.await;
// Verify final file contents
let final_path = tmp_cwd.path().join("app.py");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(
contents,
r#"class BaseClass:
def method():
return True
"#
);
Ok(())
}

View File

@@ -0,0 +1,73 @@
// this file is only used for e2e tests which are currently disabled on windows
#![cfg(not(target_os = "windows"))]
#![allow(clippy::expect_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use core_test_support::load_sse_fixture_with_id_from_str;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::Respond;
struct SeqResponder {
num_calls: AtomicUsize,
responses: Vec<String>,
}
impl Respond for SeqResponder {
fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate {
let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst);
match self.responses.get(call_num) {
Some(body) => wiremock::ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(body, &format!("request_{}", call_num)),
"text/event-stream",
),
None => panic!("no response for {call_num}"),
}
}
}
/// Helper function to run an E2E test of a codex-exec call. Starts a wiremock
/// server, and returns the response_streams in order for each api call. Runs
/// the codex-exec command with the wiremock server as the model server.
pub(crate) async fn run_e2e_exec_test(cwd: &Path, response_streams: Vec<String>) {
let server = MockServer::start().await;
let num_calls = response_streams.len();
let seq_responder = SeqResponder {
num_calls: AtomicUsize::new(0),
responses: response_streams,
};
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(seq_responder)
.expect(num_calls as u64)
.mount(&server)
.await;
let cwd = cwd.to_path_buf();
let uri = server.uri();
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")
.expect("should find binary for codex-exec")
.current_dir(cwd.clone())
.env("CODEX_HOME", cwd.clone())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", uri))
.arg("--skip-git-repo-check")
.arg("-s")
.arg("danger-full-access")
.arg("foo")
.assert()
.success();
}

View File

@@ -0,0 +1,4 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod common;
mod sandbox;

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,10 @@
// Aggregates all former standalone integration tests as modules.
mod bad;
mod cp;
mod good;
mod head;
mod literal;
mod ls;
mod parse_sed_command;
mod pwd;
mod sed;

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,2 @@
// Aggregates all former standalone integration tests as modules.
mod landlock;

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,2 @@
// Aggregates all former standalone integration tests as modules.
mod login_server_e2e;

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,8 @@
// Aggregates all former standalone integration tests as modules.
mod auth;
mod codex_message_processor_flow;
mod codex_tool;
mod create_conversation;
mod interrupt;
mod login;
mod send_message;

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

View File

@@ -0,0 +1,3 @@
// Aggregates all former standalone integration tests as modules.
mod initialize;
mod progress_notification;

View File

@@ -48,6 +48,8 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;
// Prepend header to each generated .ts file
let ts_files = ts_files_in(out_dir)?;
for file in &ts_files {
@@ -109,5 +111,39 @@ fn ts_files_in(dir: &Path) -> Result<Vec<PathBuf>> {
files.push(path);
}
}
files.sort();
Ok(files)
}
/// Generate an index.ts file that re-exports all generated types.
/// This allows consumers to import all types from a single file.
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<String> = Vec::new();
let mut stems: Vec<String> = ts_files_in(out_dir)?
.into_iter()
.filter_map(|p| {
let stem = p.file_stem()?.to_string_lossy().into_owned();
if stem == "index" { None } else { Some(stem) }
})
.collect();
stems.sort();
stems.dedup();
for name in stems {
entries.push(format!("export type {{ {name} }} from \"./{name}\";\n"));
}
let mut content =
String::with_capacity(HEADER.len() + entries.iter().map(|s| s.len()).sum::<usize>());
content.push_str(HEADER);
for line in &entries {
content.push_str(line);
}
let index_path = out_dir.join("index.ts");
let mut f = fs::File::create(&index_path)
.with_context(|| format!("Failed to create {}", index_path.display()))?;
f.write_all(content.as_bytes())
.with_context(|| format!("Failed to write {}", index_path.display()))?;
Ok(index_path)
}

View File

@@ -28,16 +28,6 @@ pub(crate) trait BottomPaneView {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Update the status indicator animated header. Default no-op.
fn update_status_header(&mut self, _header: String) {
// no-op
}
/// Called when task completes to check if the view should be hidden.
fn should_hide_when_task_is_done(&mut self) -> bool {
false
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
@@ -46,8 +36,4 @@ pub(crate) trait BottomPaneView {
) -> Option<ApprovalRequest> {
Some(request)
}
/// Optional hook for views that expose a live status line. Views that do not
/// support this can ignore the call.
fn update_status_text(&mut self, _text: String) {}
}

View File

@@ -155,7 +155,7 @@ impl ChatComposer {
ActivePopup::None => 1,
};
let [textarea_rect, _] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
Layout::vertical([Constraint::Min(1), Constraint::Max(popup_height)]).areas(area);
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
@@ -232,6 +232,20 @@ impl ChatComposer {
true
}
/// Replace the entire composer content with `text` and reset cursor.
pub(crate) fn set_text_content(&mut self, text: String) {
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
self.sync_command_popup();
self.sync_file_search_popup();
}
/// Get the current composer text.
#[cfg(test)]
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
}
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
let placeholder = format!("[image {width}x{height} {format_label}]");
// Insert as an element to match large paste placeholder behavior:
@@ -1099,7 +1113,7 @@ impl ChatComposer {
}
}
impl WidgetRef for &ChatComposer {
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
@@ -1107,7 +1121,7 @@ impl WidgetRef for &ChatComposer {
ActivePopup::None => 1,
};
let [textarea_rect, popup_rect] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
Layout::vertical([Constraint::Min(1), Constraint::Max(popup_height)]).areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
@@ -1496,7 +1510,7 @@ mod tests {
}
terminal
.draw(|f| f.render_widget_ref(&composer, f.area()))
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
assert_snapshot!(name, terminal.backend());

View File

@@ -24,7 +24,6 @@ mod list_selection_view;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod status_indicator_view;
mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,10 +35,10 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
pub(crate) struct BottomPane {
@@ -47,7 +46,7 @@ pub(crate) struct BottomPane {
/// input state is retained when the view is closed.
composer: ChatComposer,
/// If present, this is displayed instead of the `composer`.
/// If present, this is displayed instead of the `composer` (e.g. modals).
active_view: Option<Box<dyn BottomPaneView>>,
app_event_tx: AppEventSender,
@@ -58,9 +57,10 @@ pub(crate) struct BottomPane {
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
/// Queued user messages to show under the status indicator.
queued_user_messages: Vec<String>,
}
pub(crate) struct BottomPaneParams {
@@ -88,42 +88,60 @@ impl BottomPane {
has_input_focus: params.has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
queued_user_messages: Vec::new(),
esc_backtrack_hint: false,
status_view_active: false,
}
}
pub fn desired_height(&self, width: u16) -> u16 {
let view_height = if let Some(view) = self.active_view.as_ref() {
let top_margin = if self.active_view.is_some() { 0 } else { 1 };
// Base height depends on whether a modal/overlay is active.
let mut base = if let Some(view) = self.active_view.as_ref() {
view.desired_height(width)
} else {
self.composer.desired_height(width)
};
let top_pad = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};
view_height
.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_pad)
// If a status indicator is active and no modal is covering the composer,
// include its height above the composer.
if self.active_view.is_none()
&& let Some(status) = self.status.as_ref()
{
base = base.saturating_add(status.desired_height(width));
}
// Account for bottom padding rows. Top spacing is handled in layout().
base.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_margin)
}
fn layout(&self, area: Rect) -> Rect {
let top = if self.active_view.is_none() || self.status_view_active {
1
fn layout(&self, area: Rect) -> [Rect; 2] {
// Prefer showing the status header when space is extremely tight.
// Drop the top spacer if there is only one row available.
let mut top_margin = if self.active_view.is_some() { 0 } else { 1 };
if area.height <= 1 {
top_margin = 0;
}
let status_height = if self.active_view.is_none() {
if let Some(status) = self.status.as_ref() {
status.desired_height(area.width)
} else {
0
}
} else {
0
};
let [_, content, _] = Layout::vertical([
Constraint::Max(top),
let [_, status, content, _] = Layout::vertical([
Constraint::Max(top_margin),
Constraint::Max(status_height),
Constraint::Min(1),
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
])
.areas(area);
content
[status, content]
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -131,10 +149,10 @@ impl BottomPane {
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() || self.status_view_active {
if self.active_view.is_some() {
None
} else {
let content = self.layout(area);
let [_, content] = self.layout(area);
self.composer.cursor_pos(content)
}
}
@@ -145,18 +163,21 @@ impl BottomPane {
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
} else if self.is_task_running {
let mut v = StatusIndicatorView::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
);
v.update_text("waiting for model".to_string());
self.active_view = Some(Box::new(v));
self.status_view_active = true;
}
self.request_redraw();
InputResult::None
} else {
// If a task is running and a status line is visible, allow Esc to
// send an interrupt even while the composer has focus.
if matches!(key_event.code, crossterm::event::KeyCode::Esc)
&& self.is_task_running
&& let Some(status) = &self.status
{
// Send Op::Interrupt
status.interrupt();
self.request_redraw();
return InputResult::None;
}
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
@@ -178,15 +199,6 @@ impl BottomPane {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
} else if self.is_task_running {
// Modal aborted but task still running restore status indicator.
let mut v = StatusIndicatorView::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
);
v.update_text("waiting for model".to_string());
self.active_view = Some(Box::new(v));
self.status_view_active = true;
}
self.show_ctrl_c_quit_hint();
}
@@ -211,13 +223,24 @@ impl BottomPane {
self.request_redraw();
}
/// Replace the composer text with `text`.
pub(crate) fn set_composer_text(&mut self, text: String) {
self.composer.set_text_content(text);
self.request_redraw();
}
/// Get the current composer text (for tests and programmatic checks).
#[cfg(test)]
pub(crate) fn composer_text(&self) -> String {
self.composer.current_text()
}
/// Update the animated header shown to the left of the brackets in the
/// status indicator (defaults to "Working"). This will update the active
/// StatusIndicatorView if present; otherwise, if a live overlay is active,
/// it will update that. If neither is present, this call is a no-op.
/// status indicator (defaults to "Working"). No-ops if the status
/// indicator is not active.
pub(crate) fn update_status_header(&mut self, header: String) {
if let Some(view) = self.active_view.as_mut() {
view.update_status_header(header.clone());
if let Some(status) = self.status.as_mut() {
status.update_header(header);
self.request_redraw();
}
}
@@ -262,23 +285,19 @@ impl BottomPane {
self.is_task_running = running;
if running {
if self.active_view.is_none() {
self.active_view = Some(Box::new(StatusIndicatorView::new(
if self.status.is_none() {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
)));
self.status_view_active = true;
));
}
if let Some(status) = self.status.as_mut() {
status.set_queued_messages(self.queued_user_messages.clone());
}
self.request_redraw();
} else {
// Drop the status view when a task completes, but keep other
// modal views (e.g. approval dialogs).
if let Some(mut view) = self.active_view.take() {
if !view.should_hide_when_task_is_done() {
self.active_view = Some(view);
}
self.status_view_active = false;
}
// Hide the status indicator when a task completes, but keep other modal views.
self.status = None;
}
}
@@ -298,21 +317,16 @@ impl BottomPane {
self.app_event_tx.clone(),
);
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Update the live status text shown while a task is running.
/// If a modal view is active (i.e., not the status indicator), this is a noop.
pub(crate) fn update_status_text(&mut self, text: String) {
if !self.is_task_running || !self.status_view_active {
return;
}
if let Some(mut view) = self.active_view.take() {
view.update_status_text(text);
self.active_view = Some(view);
self.request_redraw();
/// Update the queued messages shown under the status header.
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
self.queued_user_messages = queued.clone();
if let Some(status) = self.status.as_mut() {
status.set_queued_messages(queued);
}
self.request_redraw();
}
pub(crate) fn composer_is_empty(&self) -> bool {
@@ -353,7 +367,6 @@ impl BottomPane {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.active_view = Some(Box::new(modal));
self.status_view_active = false;
self.request_redraw()
}
@@ -409,12 +422,20 @@ impl BottomPane {
impl WidgetRef for &BottomPane {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let content = self.layout(area);
let [status_area, content] = self.layout(area);
// When a modal view is active, it owns the whole content area.
if let Some(view) = &self.active_view {
view.render(content, buf);
} else {
(&self.composer).render_ref(content, buf);
// No active modal:
// If a status indicator is active, render it above the composer.
if let Some(status) = &self.status {
status.render_ref(status_area, buf);
}
// Render the composer in the remaining area.
self.composer.render_ref(content, buf);
}
}
}
@@ -485,7 +506,7 @@ mod tests {
}
#[test]
fn composer_not_shown_after_denied_if_task_running() {
fn composer_shown_after_denied_while_task_running() {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
@@ -496,7 +517,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
});
// Start a running task so the status indicator replaces the composer.
// Start a running task so the status indicator is active above the composer.
pane.set_task_running(true);
// Push an approval modal (e.g., command approval) which should hide the status view.
@@ -508,16 +529,17 @@ mod tests {
use crossterm::event::KeyModifiers;
pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
// After denial, since the task is still running, the status indicator
// should be restored as the active view; the composer should NOT be visible.
// After denial, since the task is still running, the status indicator should be
// visible above the composer. The modal should be gone.
assert!(
pane.status_view_active,
"status view should be active after denial"
pane.active_view.is_none(),
"no active modal view after denial"
);
assert!(pane.active_view.is_some(), "active view should be present");
// Render and ensure the top row includes the Working header instead of the composer.
let area = Rect::new(0, 0, 40, 3);
// Render and ensure the top row includes the Working header and a composer line below.
// Give the animation thread a moment to tick.
std::thread::sleep(std::time::Duration::from_millis(120));
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
let mut row1 = String::new();
@@ -529,6 +551,23 @@ mod tests {
"expected Working header after denial on row 1: {row1:?}"
);
// Composer placeholder should be visible somewhere below.
let mut found_composer = false;
for y in 1..area.height.saturating_sub(2) {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("Ask Codex") {
found_composer = true;
break;
}
}
assert!(
found_composer,
"expected composer visible under status line"
);
// Drain the channel to avoid unused warnings.
drop(rx);
}
@@ -548,7 +587,8 @@ mod tests {
// Begin a task: show initial status.
pane.set_task_running(true);
let area = Rect::new(0, 0, 40, 3);
// Use a height that allows the status line to be visible above the composer.
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
@@ -563,7 +603,7 @@ mod tests {
}
#[test]
fn bottom_padding_present_for_status_view() {
fn bottom_padding_present_with_status_above_composer() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
@@ -592,19 +632,29 @@ mod tests {
for x in 0..area.width {
top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 1)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(
top.trim_start().starts_with("Working"),
"expected top row to start with 'Working': {top:?}"
);
assert!(
top.contains("Working"),
"expected Working header on top row: {top:?}"
);
// Bottom two rows are blank padding
// Next row (spacer) is blank, and bottom two rows are blank padding
let mut spacer = String::new();
let mut r_last = String::new();
let mut r_last2 = String::new();
for x in 0..area.width {
// Spacer row immediately below the status header lives at y=2.
spacer.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' '));
r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' '));
}
assert!(
spacer.trim().is_empty(),
"expected spacer line blank: {spacer:?}"
);
assert!(
r_last.trim().is_empty(),
"expected last row blank: {r_last:?}"
@@ -629,7 +679,7 @@ mod tests {
pane.set_task_running(true);
// Height=2 → with spacer, spinner on row 1; no bottom padding.
// Height=2 → composer visible; status is hidden to preserve composer. Spacer may collapse.
let area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2);
@@ -639,13 +689,17 @@ mod tests {
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(row0.trim().is_empty(), "expected spacer on row 0: {row0:?}");
let has_composer = row0.contains("Ask Codex") || row1.contains("Ask Codex");
assert!(
row1.contains("Working"),
"expected Working on row 1: {row1:?}"
has_composer,
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
);
assert!(
!row0.contains("Working") && !row1.contains("Working"),
"status header should be hidden when height=2"
);
// Height=1 → no padding; single row is the spinner.
// Height=1 → no padding; single row is the composer (status hidden).
let area1 = Rect::new(0, 0, 20, 1);
let mut buf1 = Buffer::empty(area1);
(&pane).render_ref(area1, &mut buf1);
@@ -654,8 +708,8 @@ mod tests {
only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(
only.contains("Working"),
"expected Working header with no padding: {only:?}"
only.contains("Ask Codex"),
"expected composer with no padding: {only:?}"
);
}
}

View File

@@ -1,59 +0,0 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget;
use crate::tui::FrameRequester;
use super::BottomPaneView;
pub(crate) struct StatusIndicatorView {
view: StatusIndicatorWidget,
}
impl StatusIndicatorView {
pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
Self {
view: StatusIndicatorWidget::new(app_event_tx, frame_requester),
}
}
pub fn update_text(&mut self, text: String) {
self.view.update_text(text);
}
pub fn update_header(&mut self, header: String) {
self.view.update_header(header);
}
}
impl BottomPaneView for StatusIndicatorView {
fn update_status_header(&mut self, header: String) {
self.update_header(header);
}
fn should_hide_when_task_is_done(&mut self) -> bool {
true
}
fn desired_height(&self, width: u16) -> u16 {
self.view.desired_height(width)
}
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
self.view.render_ref(area, buf);
}
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
if key_event.code == KeyCode::Esc {
self.view.interrupt();
}
}
fn update_status_text(&mut self, text: String) {
self.update_text(text);
}
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
@@ -30,8 +31,10 @@ use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_protocol::parse_command::ParsedCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -100,8 +103,6 @@ pub(crate) struct ChatWidget {
task_complete_pending: bool,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Whether a redraw is needed after handling the current event
needs_redraw: bool,
// Accumulates the current reasoning block text to extract a header
reasoning_buffer: String,
// Accumulates full reasoning content for transcript-only recording
@@ -111,6 +112,8 @@ pub(crate) struct ChatWidget {
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
last_history_was_exec: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
}
struct UserMessage {
@@ -136,10 +139,6 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
#[inline]
fn mark_needs_redraw(&mut self) {
self.needs_redraw = true;
}
fn flush_answer_stream_with_separator(&mut self) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
let _ = self.stream.finalize(true, &sink);
@@ -157,14 +156,14 @@ impl ChatWidget {
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
self.mark_needs_redraw();
self.request_redraw();
}
fn on_agent_message(&mut self, message: String) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
let finished = self.stream.apply_final_answer(&message, &sink);
self.handle_if_stream_finished(finished);
self.mark_needs_redraw();
self.request_redraw();
}
fn on_agent_message_delta(&mut self, delta: String) {
@@ -183,7 +182,7 @@ impl ChatWidget {
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
}
self.mark_needs_redraw();
self.request_redraw();
}
fn on_agent_reasoning_final(&mut self) {
@@ -197,7 +196,7 @@ impl ChatWidget {
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.mark_needs_redraw();
self.request_redraw();
}
fn on_reasoning_section_break(&mut self) {
@@ -215,7 +214,7 @@ impl ChatWidget {
self.stream.reset_headers_for_new_turn();
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.mark_needs_redraw();
self.request_redraw();
}
fn on_task_complete(&mut self) {
@@ -228,7 +227,10 @@ impl ChatWidget {
// Mark task stopped and request redraw now that all content is in history.
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.mark_needs_redraw();
self.request_redraw();
// If there is a queued user message, send exactly one now to begin the next turn.
self.maybe_send_next_queued_input();
}
fn on_token_count(&mut self, token_usage: TokenUsage) {
@@ -246,7 +248,10 @@ impl ChatWidget {
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.stream.clear_all();
self.mark_needs_redraw();
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
self.maybe_send_next_queued_input();
}
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
@@ -349,7 +354,7 @@ impl ChatWidget {
fn on_stream_error(&mut self, message: String) {
// Show stream errors in the transcript so users see retry/backoff info.
self.add_to_history(history_cell::new_stream_error_event(message));
self.mark_needs_redraw();
self.request_redraw();
}
/// Periodic tick to commit at most one queued line to history with a small delay,
/// animating the output.
@@ -403,7 +408,7 @@ impl ChatWidget {
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.stream.begin(&sink);
self.stream.push_and_maybe_commit(&delta, &sink);
self.mark_needs_redraw();
self.request_redraw();
}
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
@@ -461,7 +466,7 @@ impl ChatWidget {
reason: ev.reason,
};
self.bottom_pane.push_approval_request(request);
self.mark_needs_redraw();
self.request_redraw();
}
pub(crate) fn handle_apply_patch_approval_now(
@@ -481,7 +486,7 @@ impl ChatWidget {
grant_root: ev.grant_root,
};
self.bottom_pane.push_approval_request(request);
self.mark_needs_redraw();
self.request_redraw();
}
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
@@ -509,7 +514,7 @@ impl ChatWidget {
}
// Request a redraw so the working header and command list are visible immediately.
self.mark_needs_redraw();
self.request_redraw();
}
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
@@ -589,11 +594,11 @@ impl ChatWidget {
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
queued_user_messages: VecDeque::new(),
show_welcome_banner: true,
}
}
@@ -634,11 +639,11 @@ impl ChatWidget {
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
queued_user_messages: VecDeque::new(),
show_welcome_banner: false,
}
}
@@ -656,13 +661,39 @@ impl ChatWidget {
self.bottom_pane.clear_ctrl_c_quit_hint();
}
// Alt+Up: Edit the most recent queued user message (if any).
if matches!(
key_event,
KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::ALT,
kind: KeyEventKind::Press,
..
}
) && !self.queued_user_messages.is_empty()
{
// Prefer the most recently queued item.
if let Some(user_message) = self.queued_user_messages.pop_back() {
self.bottom_pane.set_composer_text(user_message.text);
self.refresh_queued_user_messages();
self.request_redraw();
}
return;
}
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
let images = self.bottom_pane.take_recent_submission_images();
self.submit_user_message(UserMessage {
// If a task is running, queue the user input to be sent after the turn completes.
let user_message = UserMessage {
text,
image_paths: images,
});
image_paths: self.bottom_pane.take_recent_submission_images(),
};
if self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
self.submit_user_message(user_message);
}
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
@@ -848,8 +879,6 @@ impl ChatWidget {
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
// Reset redraw flag for this dispatch
self.needs_redraw = false;
let Event { id, msg } = event;
match msg {
@@ -913,27 +942,40 @@ impl ChatWidget {
.send(crate::app_event::AppEvent::ConversationHistory(ev));
}
}
// Coalesce redraws: issue at most one after handling the event
if self.needs_redraw {
self.request_redraw();
self.needs_redraw = false;
}
}
fn request_redraw(&mut self) {
self.frame_requester.schedule_frame();
}
// If idle and there are queued inputs, submit exactly one to start the next turn.
fn maybe_send_next_queued_input(&mut self) {
if self.bottom_pane.is_task_running() {
return;
}
if let Some(user_message) = self.queued_user_messages.pop_front() {
self.submit_user_message(user_message);
}
// Update the list to reflect the remaining queued messages (if any).
self.refresh_queued_user_messages();
}
/// Rebuild and update the queued user messages from the current queue.
fn refresh_queued_user_messages(&mut self) {
let messages: Vec<String> = self
.queued_user_messages
.iter()
.map(|m| m.text.clone())
.collect();
self.bottom_pane.set_queued_user_messages(messages);
}
pub(crate) fn add_diff_in_progress(&mut self) {
self.bottom_pane.set_task_running(true);
self.bottom_pane
.update_status_text("computing diff".to_string());
self.request_redraw();
}
pub(crate) fn on_diff_complete(&mut self) {
self.bottom_pane.set_task_running(false);
self.mark_needs_redraw();
self.request_redraw();
}
pub(crate) fn add_status_output(&mut self) {

View File

@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"? Codex wants to run echo hello world "
" "
"Model wants to run a command "
" "
"▌Allow command? "
"▌ Yes Always No No, provide feedback "
"▌ Approve and run the command "
" "
" "

View File

@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 690
expression: terminal.backend()
---
"The model wants to apply changes "
" "
"This will grant write access to /tmp for the remainder of this session. "
" "
"▌Apply changes? "
"▌ Yes No No, provide feedback "
"▌ Approve and apply the changes "
" "
" "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" "

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" "
" "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" "

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌ Ask Codex to do anything "
" "

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 806
expression: terminal.backend()
---
" "
" Analyzing (0s • Esc to interrupt) "
" "
"▌ Ask Codex to do anything "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" "
" "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"? Codex wants to run echo 'hello world' "
" "
"Codex wants to run a command "
" "
"▌Allow command? "
"▌ Yes Always No No, provide feedback "
"▌ Approve and run the command "
" "
" "

View File

@@ -14,6 +14,7 @@ use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
@@ -177,13 +178,13 @@ fn make_chatwidget_manual() -> (
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(),
show_welcome_banner: true,
last_history_was_exec: false,
queued_user_messages: std::collections::VecDeque::new(),
};
(widget, rx, op_rx)
}
@@ -237,6 +238,36 @@ fn open_fixture(name: &str) -> std::fs::File {
File::open(name).expect("open fixture file")
}
#[test]
fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Simulate a running task so messages would normally be queued.
chat.bottom_pane.set_task_running(true);
// Seed two queued messages.
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
chat.refresh_queued_user_messages();
// Press Alt+Up to edit the most recent (last) queued message.
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
// Composer should now contain the last queued message.
assert_eq!(
chat.bottom_pane.composer_text(),
"second queued".to_string()
);
// And the queue should now contain only the remaining (older) item.
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"first queued"
);
}
#[test]
fn exec_history_cell_shows_working_then_completed() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -622,6 +653,189 @@ async fn binary_size_transcript_matches_ideal_fixture() {
assert_eq!(visible_after, ideal);
}
//
// Snapshot test: command approval modal
//
// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal
// and snapshots the visual output using the ratatui TestBackend.
#[test]
fn approval_modal_exec_snapshot() {
// Build a chat widget with manual channels to avoid spawning the agent.
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
// Inject an exec approval request to display the approval modal.
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some("Model wants to run a command".into()),
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
// 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");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal");
assert_snapshot!("approval_modal_exec", terminal.backend());
}
// Snapshot test: patch approval modal
#[test]
fn approval_modal_patch_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
// Build a small changeset and a reason/grant_root to exercise the prompt text.
let mut changes = std::collections::HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
content: "hello\nworld\n".into(),
},
);
let ev = ApplyPatchApprovalRequestEvent {
call_id: "call-approve-patch".into(),
changes,
reason: Some("The model wants to apply changes".into()),
grant_root: Some(PathBuf::from("/tmp")),
};
chat.handle_codex_event(Event {
id: "sub-approve-patch".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
// 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");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw patch approval modal");
assert_snapshot!("approval_modal_patch", terminal.backend());
}
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[test]
fn ui_snapshots_small_heights_idle() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (chat, _rx, _op_rx) = make_chatwidget_manual();
for h in [1u16, 2, 3] {
let name = format!("chat_small_idle_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw chat idle");
assert_snapshot!(name, terminal.backend());
}
}
// Snapshot test: ChatWidget at very small heights (task running)
// Validates how status + composer are presented within tight space.
#[test]
fn ui_snapshots_small_heights_task_running() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Activate status line
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TaskStarted,
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Thinking**".into(),
}),
});
for h in [1u16, 2, 3] {
let name = format!("chat_small_running_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw chat running");
assert_snapshot!(name, terminal.backend());
}
}
// Snapshot test: status widget + approval modal active together
// The modal takes precedence visually; this captures the layout with a running
// task (status indicator active) while an approval request is shown.
#[test]
fn status_widget_and_approval_modal_snapshot() {
use codex_core::protocol::ExecApprovalRequestEvent;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Begin a running task so the status indicator would be active.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TaskStarted,
});
// Provide a deterministic header for the status line.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Analyzing**".into(),
}),
});
// Now show an approval modal (e.g. exec approval).
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-exec".into(),
command: vec!["echo".into(), "hello world".into()],
cwd: std::path::PathBuf::from("/tmp"),
reason: Some("Codex wants to run a command".into()),
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
// 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");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw status + approval modal");
assert_snapshot!("status_widget_and_approval_modal", terminal.backend());
}
// Snapshot test: status widget active (StatusIndicatorView)
// Ensures the VT100 rendering of the status indicator is stable when active.
#[test]
fn status_widget_active_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Activate the status indicator by simulating a task start.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TaskStarted,
});
// Provide a deterministic header via a bold reasoning chunk.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Analyzing**".into(),
}),
});
// Render and snapshot.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw status widget");
assert_snapshot!("status_widget_active", terminal.backend());
}
#[test]
fn apply_patch_events_emit_history_cells() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();

View File

@@ -224,7 +224,10 @@ pub(crate) fn new_session_info(
} = event;
if is_first_event {
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
Some(rel) if !rel.as_os_str().is_empty() => {
let sep = std::path::MAIN_SEPARATOR;
format!("~{sep}{}", rel.display())
}
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
@@ -594,7 +597,10 @@ pub(crate) fn new_status_output(
lines.push(Line::from(vec!["📂 ".into(), "Workspace".bold()]));
// Path (home-relative, e.g., ~/code/project)
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
Some(rel) if !rel.as_os_str().is_empty() => {
let sep = std::path::MAIN_SEPARATOR;
format!("~{sep}{}", rel.display())
}
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
@@ -637,7 +643,8 @@ pub(crate) fn new_status_output(
ups += 1;
}
if reached {
format!("{}AGENTS.md", "../".repeat(ups))
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
stripped.display().to_string()
} else {

View File

@@ -0,0 +1,6 @@
---
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • Esc t"
" "

View File

@@ -0,0 +1,12 @@
---
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • Esc to interrupt) "
" ↳ first "
" ↳ second "
" Alt+↑ edit "
" "
" "
" "
" "

View File

@@ -0,0 +1,6 @@
---
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • Esc to interrupt) "
" "

View File

@@ -7,39 +7,24 @@ use std::time::Instant;
use codex_core::protocol::Op;
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;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
// We render the live text using markdown so it visually matches the history
// cells. Before rendering we strip any ANSI escape sequences to avoid writing
// raw control bytes into the back buffer.
use codex_ansi_escape::ansi_escape_line;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
pub(crate) struct StatusIndicatorWidget {
/// Latest text to display (truncated to the available width at render
/// time).
text: String,
/// Animated header text (defaults to "Working").
header: String,
/// Queued user messages to display under the status line.
queued_messages: Vec<String>,
/// Animation state: reveal target `text` progressively like a typewriter.
/// We compute the currently visible prefix length based on the current
/// frame index and a constant typing speed. The `base_frame` and
/// `reveal_len_at_base` form the anchor from which we advance.
last_target_len: usize,
base_frame: usize,
reveal_len_at_base: usize,
start_time: Instant,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
@@ -48,11 +33,8 @@ pub(crate) struct StatusIndicatorWidget {
impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
Self {
text: String::from("waiting for model"),
header: String::from("Working"),
last_target_len: 0,
base_frame: 0,
reveal_len_at_base: 0,
queued_messages: Vec::new(),
start_time: Instant::now(),
app_event_tx,
@@ -60,38 +42,32 @@ impl StatusIndicatorWidget {
}
}
pub fn desired_height(&self, _width: u16) -> u16 {
1
}
/// Update the line that is displayed in the widget.
pub(crate) fn update_text(&mut self, text: String) {
// If the text hasn't changed, don't reset the baseline; let the
// animation continue advancing naturally.
if text == self.text {
return;
pub fn desired_height(&self, width: u16) -> u16 {
// Status line + wrapped queued messages (up to 3 lines per message)
// + optional ellipsis line per truncated message + 1 spacer line
let inner_width = width.max(1) as usize;
let mut total: u16 = 1; // status line
let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix
if text_width > 0 {
let opts = TwOptions::new(text_width)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
for q in &self.queued_messages {
let wrapped = textwrap::wrap(q, &opts);
let lines = wrapped.len().min(3) as u16;
total = total.saturating_add(lines);
if wrapped.len() > 3 {
total = total.saturating_add(1); // ellipsis line
}
}
if !self.queued_messages.is_empty() {
total = total.saturating_add(1); // keybind hint line
}
} else {
// At least one line per message if width is extremely narrow
total = total.saturating_add(self.queued_messages.len() as u16);
}
// Update the target text, preserving newlines so wrapping matches history cells.
// Strip ANSI escapes for the character count so the typewriter animation speed is stable.
let stripped = {
let line = ansi_escape_line(&text);
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
// Compute how many characters are currently revealed so we can carry
// this forward as the new baseline when target text changes.
let current_frame = self.current_frame();
let shown_now = self.current_shown_len(current_frame);
self.text = text;
self.last_target_len = new_len;
self.base_frame = current_frame;
self.reveal_len_at_base = shown_now.min(new_len);
total.saturating_add(1) // spacer line
}
pub(crate) fn interrupt(&self) {
@@ -105,125 +81,57 @@ impl StatusIndicatorWidget {
}
}
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
let sanitized = text.replace(['\n', '\r'], " ");
let stripped = {
let line = ansi_escape_line(&sanitized);
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
let current_frame = self.current_frame();
self.text = sanitized;
self.last_target_len = new_len;
self.base_frame = current_frame;
// Start from zero revealed characters for a fresh typewriter cycle.
self.reveal_len_at_base = 0;
}
/// Calculate how many characters should currently be visible given the
/// animation baseline and frame counter.
fn current_shown_len(&self, current_frame: usize) -> usize {
// Increase typewriter speed (~5x): reveal more characters per frame.
const TYPING_CHARS_PER_FRAME: usize = 7;
let frames = current_frame.saturating_sub(self.base_frame);
let advanced = self
.reveal_len_at_base
.saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME));
advanced.min(self.last_target_len)
}
fn current_frame(&self) -> usize {
// Derive frame index from wall-clock time. 100ms per frame to match
// the previous ticker cadence.
let since_start = self.start_time.elapsed();
(since_start.as_millis() / 100) as usize
}
/// Test-only helper to fast-forward the internal clock so animations
/// advance without sleeping.
#[cfg(test)]
pub(crate) fn test_fast_forward_frames(&mut self, frames: usize) {
let advance_ms = (frames as u64).saturating_mul(100);
// Move the start time into the past so `current_frame()` advances.
self.start_time = std::time::Instant::now() - std::time::Duration::from_millis(advance_ms);
/// Replace the queued messages displayed beneath the header.
pub(crate) fn set_queued_messages(&mut self, queued: Vec<String>) {
self.queued_messages = queued;
// Ensure a redraw so changes are visible.
self.frame_requester.schedule_frame();
}
}
impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Ensure minimal height
if area.height == 0 || area.width == 0 {
if area.is_empty() {
return;
}
// Schedule next animation frame.
self.frame_requester
.schedule_frame_in(Duration::from_millis(32));
let idx = self.current_frame();
let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx);
let status_prefix: String = self.text.chars().take(shown_now).collect();
let animated_spans = shimmer_spans(&self.header);
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;
let mut spans = vec![" ".into()];
spans.extend(shimmer_spans(&self.header));
spans.extend(vec![
" ".into(),
format!("({elapsed}s • ").dim(),
"Esc".dim().bold(),
" to interrupt)".dim(),
]);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("", Style::default().fg(Color::Cyan)));
// Animated header after the left bar
spans.extend(animated_spans);
// Space between header and bracket block
spans.push(Span::raw(" "));
// Non-animated, dim bracket content, with keys bold
let bracket_prefix = format!("({elapsed}s • ");
spans.push(Span::styled(
bracket_prefix,
Style::default().add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
"Esc",
Style::default().add_modifier(Modifier::DIM | Modifier::BOLD),
));
spans.push(Span::styled(
" to interrupt)",
Style::default().add_modifier(Modifier::DIM),
));
// Add a space and then the log text (not animated by the gradient)
if !status_prefix.is_empty() {
spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
status_prefix,
Style::default().add_modifier(Modifier::DIM),
));
}
// Truncate spans to fit the width.
let mut acc: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for s in spans {
let w = s.content.width();
if used + w <= inner_width {
acc.push(s);
used += w;
} else {
break;
// Build lines: status, then queued messages, then spacer.
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(spans));
// Wrap queued messages using textwrap and show up to the first 3 lines per message.
let text_width = area.width.saturating_sub(3); // " ↳ " prefix
let opts = TwOptions::new(text_width as usize)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
for q in &self.queued_messages {
let wrapped = textwrap::wrap(q, &opts);
for (i, piece) in wrapped.iter().take(3).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let content = format!("{prefix}{piece}");
lines.push(Line::from(content.dim()));
}
if wrapped.len() > 3 {
lines.push(Line::from("".dim()));
}
}
let lines = vec![Line::from(acc)];
// No-op once full text is revealed; the app no longer reacts to a completion event.
if !self.queued_messages.is_empty() {
lines.push(Line::from(vec![" ".into(), "Alt+↑".cyan(), " edit".into()]).dim());
}
let paragraph = Paragraph::new(lines);
paragraph.render_ref(area, buf);
@@ -235,60 +143,51 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn renders_without_left_border_or_padding() {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
w.restart_with_text("Hello".to_string());
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
// Advance animation without sleeping.
w.test_fast_forward_frames(2);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
// Leftmost column has the left bar
let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' ');
assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}");
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
}
#[test]
fn working_header_is_present_on_last_line() {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
w.restart_with_text("Hi".to_string());
// Advance animation without sleeping.
w.test_fast_forward_frames(2);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
// Single line; it should contain the animated "Working" header.
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(row.contains("Working"), "expected Working header: {row:?}");
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
}
#[test]
fn header_starts_at_expected_position() {
fn renders_with_queued_messages() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
w.restart_with_text("Hello".to_string());
w.test_fast_forward_frames(2);
w.set_queued_messages(vec!["first".to_string(), "second".to_string()]);
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
let ch = buf[(2, 0)].symbol().chars().next().unwrap_or(' ');
assert_eq!(ch, 'W', "expected Working header at col 2: {ch:?}");
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("terminal");
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
}
}

View File

@@ -373,7 +373,11 @@ impl UserApprovalWidget {
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
// Reserve space for:
// - 1 title line ("Allow command?" or "Apply changes?")
// - 1 buttons line (options rendered horizontally on a single row)
// - 1 description line (context for the currently selected option)
self.get_confirmation_prompt_height(width) + 3
}
}

View File

@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;

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