mirror of
https://github.com/openai/codex.git
synced 2026-02-05 08:23:41 +00:00
Compare commits
15 Commits
fix/compac
...
codex--app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d8bde988 | ||
|
|
7f7d1e30f3 | ||
|
|
568d6f819f | ||
|
|
251c4c2ba9 | ||
|
|
a6c346b9e1 | ||
|
|
e307040f10 | ||
|
|
7d67e54628 | ||
|
|
295ca27e98 | ||
|
|
7b20db942a | ||
|
|
ee2ccb5cb6 | ||
|
|
8b49346657 | ||
|
|
e49116a4c5 | ||
|
|
517ffd00c6 | ||
|
|
4157788310 | ||
|
|
32bbbbad61 |
15
codex-rs/Cargo.lock
generated
15
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
3
codex-rs/apply-patch/src/main.rs
Normal file
3
codex-rs/apply-patch/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn main() -> ! {
|
||||
codex_apply_patch::main()
|
||||
}
|
||||
@@ -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
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
59
codex-rs/apply-patch/src/standalone_executable.rs
Normal file
59
codex-rs/apply-patch/src/standalone_executable.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
3
codex-rs/apply-patch/tests/all.rs
Normal file
3
codex-rs/apply-patch/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
90
codex-rs/apply-patch/tests/suite/cli.rs
Normal file
90
codex-rs/apply-patch/tests/suite/cli.rs
Normal 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(())
|
||||
}
|
||||
1
codex-rs/apply-patch/tests/suite/mod.rs
Normal file
1
codex-rs/apply-patch/tests/suite/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
mod cli;
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
3
codex-rs/chatgpt/tests/all.rs
Normal file
3
codex-rs/chatgpt/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
2
codex-rs/chatgpt/tests/suite/mod.rs
Normal file
2
codex-rs/chatgpt/tests/suite/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_command_e2e;
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
codex-rs/core/tests/all.rs
Normal file
3
codex-rs/core/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/all/`.
|
||||
mod suite;
|
||||
12
codex-rs/core/tests/suite/mod.rs
Normal file
12
codex-rs/core/tests/suite/mod.rs
Normal 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;
|
||||
@@ -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"];
|
||||
3
codex-rs/exec/tests/all.rs
Normal file
3
codex-rs/exec/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
@@ -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(())
|
||||
}
|
||||
4
codex-rs/exec/tests/fixtures/apply_patch_freeform_final.txt
vendored
Normal file
4
codex-rs/exec/tests/fixtures/apply_patch_freeform_final.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
class BaseClass:
|
||||
def method():
|
||||
|
||||
return True
|
||||
25
codex-rs/exec/tests/fixtures/sse_apply_patch_add.json
vendored
Normal file
25
codex-rs/exec/tests/fixtures/sse_apply_patch_add.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
25
codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json
vendored
Normal file
25
codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
25
codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json
vendored
Normal file
25
codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
25
codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json
vendored
Normal file
25
codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
25
codex-rs/exec/tests/fixtures/sse_apply_patch_update.json
vendored
Normal file
25
codex-rs/exec/tests/fixtures/sse_apply_patch_update.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
16
codex-rs/exec/tests/fixtures/sse_response_completed.json
vendored
Normal file
16
codex-rs/exec/tests/fixtures/sse_response_completed.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
146
codex-rs/exec/tests/suite/apply_patch.rs
Normal file
146
codex-rs/exec/tests/suite/apply_patch.rs
Normal 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(())
|
||||
}
|
||||
73
codex-rs/exec/tests/suite/common.rs
Normal file
73
codex-rs/exec/tests/suite/common.rs
Normal 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();
|
||||
}
|
||||
4
codex-rs/exec/tests/suite/mod.rs
Normal file
4
codex-rs/exec/tests/suite/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_patch;
|
||||
mod common;
|
||||
mod sandbox;
|
||||
3
codex-rs/execpolicy/tests/all.rs
Normal file
3
codex-rs/execpolicy/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
10
codex-rs/execpolicy/tests/suite/mod.rs
Normal file
10
codex-rs/execpolicy/tests/suite/mod.rs
Normal 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;
|
||||
3
codex-rs/linux-sandbox/tests/all.rs
Normal file
3
codex-rs/linux-sandbox/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
2
codex-rs/linux-sandbox/tests/suite/mod.rs
Normal file
2
codex-rs/linux-sandbox/tests/suite/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod landlock;
|
||||
3
codex-rs/login/tests/all.rs
Normal file
3
codex-rs/login/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
2
codex-rs/login/tests/suite/mod.rs
Normal file
2
codex-rs/login/tests/suite/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod login_server_e2e;
|
||||
3
codex-rs/mcp-server/tests/all.rs
Normal file
3
codex-rs/mcp-server/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
8
codex-rs/mcp-server/tests/suite/mod.rs
Normal file
8
codex-rs/mcp-server/tests/suite/mod.rs
Normal 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;
|
||||
3
codex-rs/mcp-types/tests/all.rs
Normal file
3
codex-rs/mcp-types/tests/all.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
3
codex-rs/mcp-types/tests/suite/mod.rs
Normal file
3
codex-rs/mcp-types/tests/suite/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod initialize;
|
||||
mod progress_notification;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 no‑op.
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ Ask Codex to do anything "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌ Ask Codex to do anything "
|
||||
" "
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/status_indicator_widget.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Working (0s • Esc t"
|
||||
" "
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/status_indicator_widget.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Working (0s • Esc to interrupt) "
|
||||
" ↳ first "
|
||||
" ↳ second "
|
||||
" Alt+↑ edit "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/status_indicator_widget.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Working (0s • Esc to interrupt) "
|
||||
" "
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
codex-rs/tui/tests/all.rs
Normal file
3
codex-rs/tui/tests/all.rs
Normal 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
Reference in New Issue
Block a user