diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index f3b85b2b4b..8566e03490 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -21,6 +21,10 @@ "windows-x86_64": { "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", "path": "codex.exe" + }, + "windows-aarch64": { + "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex.exe" } } } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 03cedab29c..0fc0747dda 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ # External (non-OpenAI) Pull Request Requirements -Before opening this Pull Request, please read the "Contributing" section of the README or your PR may be closed: -https://github.com/openai/codex#contributing +Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: +https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 22a9291a5f..875441c536 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -100,6 +100,9 @@ jobs: - runner: windows-latest target: x86_64-pc-windows-msvc profile: dev + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + profile: dev # Also run representative release builds on Mac and Linux because # there could be release-only build errors we want to catch. @@ -114,6 +117,9 @@ jobs: - runner: windows-latest target: x86_64-pc-windows-msvc profile: release + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + profile: release steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index a6d8128a3e..a765ba6a33 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -72,6 +72,8 @@ jobs: target: aarch64-unknown-linux-gnu - runner: windows-latest target: x86_64-pc-windows-msvc + - runner: windows-11-arm + target: aarch64-pc-windows-msvc steps: - uses: actions/checkout@v5 @@ -109,6 +111,11 @@ jobs: cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" fi + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard + - name: Compress artifacts shell: bash run: | diff --git a/AGENTS.md b/AGENTS.md index 14788cb6b1..1c3f8ad825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,10 @@ In the codex-rs folder where the rust code lives: - You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations. - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate. -Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: +Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. 2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. -When running interactively, ask the user before running these commands to finalize. +When running interactively, ask the user before running `just fix` and tests to finalize; `just fmt` does not require approval. ## TUI style conventions @@ -26,6 +26,17 @@ See `codex-rs/tui/styles.md`. - Example: patch summary file lines - Desired: vec![" └ ".into(), "M".red(), " ".dim(), "tui/src/app.rs".dim()] +### TUI Styling (ratatui) +- Prefer Stylize helpers: use "text".dim(), .bold(), .cyan(), .italic(), .underlined() instead of manual Style where possible. +- Prefer simple conversions: use "text".into() for spans and vec![…].into() for lines; when inference is ambiguous (e.g., Paragraph::new/Cell::from), use Line::from(spans) or Span::from(text). +- Computed styles: if the Style is computed at runtime, using `Span::styled` is OK (`Span::from(text).set_style(style)` is also acceptable). +- Avoid hardcoded white: do not use `.white()`; prefer the default foreground (no color). +- Chaining: combine helpers by chaining for readability (e.g., url.cyan().underlined()). +- Single items: prefer "text".into(); use Line::from(text) or Span::from(text) only when the target type isn’t obvious from context, or when using .into() would require extra type annotations. +- Building lines: use vec![…].into() to construct a Line when the target type is obvious and no extra type annotations are needed; otherwise use Line::from(vec![…]). +- Avoid churn: don’t refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow file‑local conventions and do not introduce type annotations solely to satisfy .into(). +- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines. + ## Snapshot tests This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index b22c5180c0..f24065170d 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -43,7 +43,8 @@ switch (platform) { targetTriple = "x86_64-pc-windows-msvc.exe"; break; case "arm64": - // We do not build this today, fall through... + targetTriple = "aarch64-pc-windows-msvc.exe"; + break; default: break; } diff --git a/codex-cli/scripts/install_native_deps.sh b/codex-cli/scripts/install_native_deps.sh index 6cf2faafc8..b9fda2543e 100755 --- a/codex-cli/scripts/install_native_deps.sh +++ b/codex-cli/scripts/install_native_deps.sh @@ -20,7 +20,7 @@ CODEX_CLI_ROOT="" # Until we start publishing stable GitHub releases, we have to grab the binaries # from the GitHub Action that created them. Update the URL below to point to the # appropriate workflow run: -WORKFLOW_URL="https://github.com/openai/codex/actions/runs/16840150768" # rust-v0.20.0-alpha.2 +WORKFLOW_URL="https://github.com/openai/codex/actions/runs/17417194663" # rust-v0.28.0 while [[ $# -gt 0 ]]; do case "$1" in @@ -87,5 +87,8 @@ zstd -d "$ARTIFACTS_DIR/aarch64-apple-darwin/codex-aarch64-apple-darwin.zst" \ # x64 Windows zstd -d "$ARTIFACTS_DIR/x86_64-pc-windows-msvc/codex-x86_64-pc-windows-msvc.exe.zst" \ -o "$BIN_DIR/codex-x86_64-pc-windows-msvc.exe" +# ARM64 Windows +zstd -d "$ARTIFACTS_DIR/aarch64-pc-windows-msvc/codex-aarch64-pc-windows-msvc.exe.zst" \ + -o "$BIN_DIR/codex-aarch64-pc-windows-msvc.exe" echo "Installed native dependencies into $BIN_DIR" diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d7ce8f8d06..51d65d5870 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -636,10 +636,11 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "once_cell", "pretty_assertions", "similar", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tree-sitter", "tree-sitter-bash", ] @@ -665,7 +666,7 @@ dependencies = [ "clap", "codex-common", "codex-core", - "codex-login", + "codex-protocol", "reqwest", "serde", "serde_json", @@ -718,7 +719,6 @@ dependencies = [ "bytes", "chrono", "codex-apply-patch", - "codex-login", "codex-mcp-client", "codex-protocol", "core_test_support", @@ -749,7 +749,7 @@ dependencies = [ "similar", "strum_macros 0.27.2", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tokio", "tokio-test", @@ -848,6 +848,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-core", "codex-protocol", "pretty_assertions", "rand 0.8.5", @@ -856,7 +857,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tiny_http", "tokio", "url", @@ -974,11 +975,13 @@ dependencies = [ "diffy", "image", "insta", + "itertools 0.14.0", "lazy_static", "libc", "mcp-types", "once_cell", "path-clean", + "pathdiff", "pretty_assertions", "rand 0.9.2", "ratatui", @@ -2685,7 +2688,7 @@ checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -3378,6 +3381,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3851,7 +3860,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -3908,9 +3917,9 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30" [[package]] name = "regex-syntax" @@ -4971,11 +4980,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4991,9 +5000,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -5430,7 +5439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" dependencies = [ "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "ts-rs-macros", "uuid", ] diff --git a/codex-rs/ansi-escape/src/lib.rs b/codex-rs/ansi-escape/src/lib.rs index 3daaf46eda..68ea5e9aa9 100644 --- a/codex-rs/ansi-escape/src/lib.rs +++ b/codex-rs/ansi-escape/src/lib.rs @@ -9,7 +9,7 @@ use ratatui::text::Text; pub fn ansi_escape_line(s: &str) -> Line<'static> { let text = ansi_escape(s); match text.lines.as_slice() { - [] => Line::from(""), + [] => "".into(), [only] => only.clone(), [first, rest @ ..] => { tracing::warn!("ansi_escape_line: expected a single line, got {first:?} and {rest:?}"); diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 32c7f6e43f..fc8c57aa8b 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -17,9 +17,10 @@ workspace = true [dependencies] anyhow = "1" similar = "2.7.0" -thiserror = "2.0.12" +thiserror = "2.0.16" tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" +once_cell = "1" [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 84cb91201f..18d3bd4d32 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -9,6 +9,7 @@ use std::str::Utf8Error; use anyhow::Context; use anyhow::Result; +use once_cell::sync::Lazy; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; @@ -18,6 +19,9 @@ use similar::TextDiff; use thiserror::Error; use tree_sitter::LanguageError; use tree_sitter::Parser; +use tree_sitter::Query; +use tree_sitter::QueryCursor; +use tree_sitter::StreamingIterator; use tree_sitter_bash::LANGUAGE as BASH; pub use standalone_executable::main; @@ -84,6 +88,7 @@ pub enum MaybeApplyPatch { pub struct ApplyPatchArgs { pub patch: String, pub hunks: Vec, + pub workdir: Option, } pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { @@ -92,18 +97,18 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { Ok(source) => MaybeApplyPatch::Body(source), Err(e) => MaybeApplyPatch::PatchParseError(e), }, - [bash, flag, script] - if bash == "bash" - && flag == "-lc" - && APPLY_PATCH_COMMANDS - .iter() - .any(|cmd| script.trim_start().starts_with(cmd)) => - { - match extract_heredoc_body_from_apply_patch_command(script) { - Ok(body) => match parse_patch(&body) { - Ok(source) => MaybeApplyPatch::Body(source), + [bash, flag, script] if bash == "bash" && flag == "-lc" => { + match extract_apply_patch_from_bash(script) { + Ok((body, workdir)) => match parse_patch(&body) { + Ok(mut source) => { + source.workdir = workdir; + MaybeApplyPatch::Body(source) + } Err(e) => MaybeApplyPatch::PatchParseError(e), }, + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { + MaybeApplyPatch::NotApplyPatch + } Err(e) => MaybeApplyPatch::ShellParseError(e), } } @@ -116,7 +121,9 @@ pub enum ApplyPatchFileChange { Add { content: String, }, - Delete, + Delete { + content: String, + }, Update { unified_diff: String, move_path: Option, @@ -201,16 +208,42 @@ impl ApplyPatchAction { /// patch. pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { match maybe_parse_apply_patch(argv) { - MaybeApplyPatch::Body(ApplyPatchArgs { patch, hunks }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { + patch, + hunks, + workdir, + }) => { + let effective_cwd = workdir + .as_ref() + .map(|dir| { + let path = Path::new(dir); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } + }) + .unwrap_or_else(|| cwd.to_path_buf()); let mut changes = HashMap::new(); for hunk in hunks { - let path = hunk.resolve_path(cwd); + let path = hunk.resolve_path(&effective_cwd); match hunk { Hunk::AddFile { contents, .. } => { changes.insert(path, ApplyPatchFileChange::Add { content: contents }); } Hunk::DeleteFile { .. } => { - changes.insert(path, ApplyPatchFileChange::Delete); + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError( + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.display()), + source: e, + }), + ); + } + }; + changes.insert(path, ApplyPatchFileChange::Delete { content }); } Hunk::UpdateFile { move_path, chunks, .. @@ -238,7 +271,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp MaybeApplyPatchVerified::Body(ApplyPatchAction { changes, patch, - cwd: cwd.to_path_buf(), + cwd: effective_cwd, }) } MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), @@ -247,33 +280,96 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp } } -/// Attempts to extract a heredoc_body object from a string bash command like: -/// Optimistically +/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script +/// that invokes the apply_patch tool using a heredoc. /// -/// ```bash -/// bash -lc 'apply_patch < && apply_patch <<'EOF'\n...\nEOF` /// -/// # Arguments +/// Notes about matching: +/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the +/// heredoc‑redirected statement is the only top‑level statement. +/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). +/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted +/// strings, no second argument). +/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` +/// or `applypatch`. +/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. /// -/// * `src` - A string slice that holds the full command -/// -/// # Returns -/// -/// This function returns a `Result` which is: -/// -/// * `Ok(String)` - The heredoc body if the extraction is successful. -/// * `Err(anyhow::Error)` - An error if the extraction fails. -/// -fn extract_heredoc_body_from_apply_patch_command( +/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or +/// `(heredoc_body, None)` for the direct form. Errors are returned if the script +/// cannot be parsed or does not match the allowed patterns. +fn extract_apply_patch_from_bash( src: &str, -) -> std::result::Result { - if !APPLY_PATCH_COMMANDS - .iter() - .any(|cmd| src.trim_start().starts_with(cmd)) - { - return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch); - } +) -> std::result::Result<(String, Option), ExtractHeredocError> { + // This function uses a Tree-sitter query to recognize one of two + // whole-script forms, each expressed as a single top-level statement: + // + // 1. apply_patch <<'EOF'\n...\nEOF + // 2. cd && apply_patch <<'EOF'\n...\nEOF + // + // Key ideas when reading the query: + // - dots (`.`) between named nodes enforces adjacency among named children and + // anchor to the start/end of the expression. + // - we match a single redirected_statement directly under program with leading + // and trailing anchors (`.`). This ensures it is the only top-level statement + // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). + // + // Overall, we want to be conservative and only match the intended forms, as other + // forms are likely to be model errors, or incorrectly interpreted by later code. + // + // If you're editing this query, it's helpful to start by creating a debugging binary + // which will let you see the AST of an arbitrary bash script passed in, and optionally + // also run an arbitrary query against the AST. This is useful for understanding + // how tree-sitter parses the script and whether the query syntax is correct. Be sure + // to test both positive and negative cases. + static APPLY_PATCH_QUERY: Lazy = Lazy::new(|| { + let language = BASH.into(); + #[expect(clippy::expect_used)] + Query::new( + &language, + r#" + ( + program + . (redirected_statement + body: (command + name: (command_name (word) @apply_name) .) + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + + ( + program + . (redirected_statement + body: (list + . (command + name: (command_name (word) @cd_name) . + argument: [ + (word) @cd_path + (string (string_content) @cd_path) + (raw_string) @cd_raw_string + ] .) + "&&" + . (command + name: (command_name (word) @apply_name)) + .) + (#eq? @cd_name "cd") + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + "#, + ) + .expect("valid bash query") + }); let lang = BASH.into(); let mut parser = Parser::new(); @@ -285,26 +381,55 @@ fn extract_heredoc_body_from_apply_patch_command( .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; let bytes = src.as_bytes(); - let mut c = tree.root_node().walk(); + let root = tree.root_node(); - loop { - let node = c.node(); - if node.kind() == "heredoc_body" { - let text = node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)?; - return Ok(text.trim_end_matches('\n').to_owned()); - } + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); + while let Some(m) = matches.next() { + let mut heredoc_text: Option = None; + let mut cd_path: Option = None; - if c.goto_first_child() { - continue; - } - while !c.goto_next_sibling() { - if !c.goto_parent() { - return Err(ExtractHeredocError::FailedToFindHeredocBody); + for capture in m.captures.iter() { + let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; + match name { + "heredoc" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .trim_end_matches('\n') + .to_string(); + heredoc_text = Some(text); + } + "cd_path" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .to_string(); + cd_path = Some(text); + } + "cd_raw_string" => { + let raw = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)?; + let trimmed = raw + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(raw); + cd_path = Some(trimmed.to_string()); + } + _ => {} } } + + if let Some(heredoc) = heredoc_text { + return Ok((heredoc, cd_path)); + } } + + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) } #[derive(Debug, PartialEq)] @@ -705,6 +830,49 @@ mod tests { strs.iter().map(|s| s.to_string()).collect() } + // Test helpers to reduce repetition when building bash -lc heredoc scripts + fn args_bash(script: &str) -> Vec { + strs_to_strings(&["bash", "-lc", script]) + } + + fn heredoc_script(prefix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" + ) + } + + fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" + ) + } + + fn expected_single_add() -> Vec { + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string(), + }] + } + + fn assert_match(script: &str, expected_workdir: Option<&str>) { + let args = args_bash(script); + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir.as_deref(), expected_workdir); + assert_eq!(hunks, expected_single_add()); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + fn assert_not_match(script: &str) { + let args = args_bash(script); + assert!(matches!( + maybe_parse_apply_patch(&args), + MaybeApplyPatch::NotApplyPatch + )); + } + #[test] fn test_literal() { let args = strs_to_strings(&[ @@ -717,7 +885,7 @@ mod tests { ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, vec![Hunk::AddFile { @@ -742,7 +910,7 @@ mod tests { ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, vec![Hunk::AddFile { @@ -757,29 +925,7 @@ mod tests { #[test] fn test_heredoc() { - let args = strs_to_strings(&[ - "bash", - "-lc", - r#"apply_patch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } + assert_match(&heredoc_script(""), None); } #[test] @@ -796,7 +942,8 @@ PATCH"#, ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir, None); assert_eq!( hunks, vec![Hunk::AddFile { @@ -809,6 +956,69 @@ PATCH"#, } } + #[test] + fn test_heredoc_with_leading_cd() { + assert_match(&heredoc_script("cd foo && "), Some("foo")); + } + + #[test] + fn test_cd_with_semicolon_is_ignored() { + assert_not_match(&heredoc_script("cd foo; ")); + } + + #[test] + fn test_cd_or_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar || ")); + } + + #[test] + fn test_cd_pipe_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar | ")); + } + + #[test] + fn test_cd_single_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); + } + + #[test] + fn test_cd_double_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); + } + + #[test] + fn test_echo_and_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("echo foo && ")); + } + + #[test] + fn test_apply_patch_with_arg_is_ignored() { + let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; + assert_not_match(script); + } + + #[test] + fn test_double_cd_then_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd foo && cd bar && ")); + } + + #[test] + fn test_cd_two_args_is_ignored() { + assert_not_match(&heredoc_script("cd foo bar && ")); + } + + #[test] + fn test_cd_then_apply_patch_then_extra_is_ignored() { + let script = heredoc_script_ps("cd bar && ", " && echo done"); + assert_not_match(&script); + } + + #[test] + fn test_echo_then_cd_and_apply_patch_is_ignored() { + // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. + assert_not_match(&heredoc_script("echo foo; cd bar && ")); + } + #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index ff9dfd6f8f..3b89c4da85 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -175,7 +175,11 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result( path: String, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?; // Make direct HTTP request to ChatGPT backend API with the token - let client = reqwest::Client::new(); + let client = create_client(&config.responses_originator_header); let url = format!("{chatgpt_base_url}{path}"); let token = @@ -31,7 +31,6 @@ pub(crate) async fn chatgpt_get_request( .bearer_auth(&token.access_token) .header("chatgpt-account-id", account_id?) .header("Content-Type", "application/json") - .header("User-Agent", get_codex_user_agent(None)) .send() .await .context("Failed to send request")?; diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index f003c4392b..c51be9eccf 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -1,10 +1,10 @@ -use codex_login::AuthMode; -use codex_login::CodexAuth; +use codex_core::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; -use codex_login::TokenData; +use codex_core::token_data::TokenData; static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); @@ -19,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) { } /// Initialize the ChatGPT token from auth.json file -pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?; +pub async fn init_chatgpt_token_from_auth( + codex_home: &Path, + originator: &str, +) -> std::io::Result<()> { + let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT, originator)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 959bf46f18..b8a8e45e02 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,14 +1,14 @@ use codex_common::CliConfigOverrides; +use codex_core::CodexAuth; +use codex_core::auth::CLIENT_ID; +use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +use codex_core::auth::login_with_api_key; +use codex_core::auth::logout; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_login::AuthMode; -use codex_login::CLIENT_ID; -use codex_login::CodexAuth; -use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_login::ServerOptions; -use codex_login::login_with_api_key; -use codex_login::logout; use codex_login::run_login_server; +use codex_protocol::mcp_protocol::AuthMode; use std::env; use std::path::PathBuf; @@ -60,7 +60,11 @@ pub async fn run_login_with_api_key( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); - match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home( + &config.codex_home, + config.preferred_auth_method, + &config.responses_originator_header, + ) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 9f8c4d3b00..8fd8e30df2 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use clap::Parser; use codex_common::CliConfigOverrides; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; @@ -9,7 +10,6 @@ use codex_core::config::ConfigOverrides; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::Submission; -use codex_login::AuthManager; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tracing::error; @@ -40,6 +40,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { let conversation_manager = ConversationManager::new(AuthManager::shared( config.codex_home.clone(), config.preferred_auth_method, + config.responses_originator_header.clone(), )); let NewConversation { conversation_id: _, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d59448baba..598985b384 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,7 +18,6 @@ base64 = "0.22" bytes = "1.10.1" chrono = { version = "0.4", features = ["serde"] } codex-apply-patch = { path = "../apply-patch" } -codex-login = { path = "../login" } codex-mcp-client = { path = "../mcp-client" } codex-protocol = { path = "../protocol" } dirs = "6" @@ -31,7 +30,7 @@ mime_guess = "2.0" os_info = "3.12.0" portable-pty = "0.9.0" rand = "0.9" -regex-lite = "0.1.6" +regex-lite = "0.1.7" reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" @@ -41,8 +40,8 @@ shlex = "1.3.0" similar = "2.7.0" strum_macros = "0.27.2" tempfile = "3" -thiserror = "2.0.12" -time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } +thiserror = "2.0.16" +time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 56fd01feb1..0afc06e99c 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -109,7 +109,9 @@ pub(crate) fn convert_apply_patch_to_protocol( ApplyPatchFileChange::Add { content } => FileChange::Add { content: content.clone(), }, - ApplyPatchFileChange::Delete => FileChange::Delete, + ApplyPatchFileChange::Delete { content } => FileChange::Delete { + content: content.clone(), + }, ApplyPatchFileChange::Update { unified_diff, move_path, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs new file mode 100644 index 0000000000..776a111cf2 --- /dev/null +++ b/codex-rs/core/src/auth.rs @@ -0,0 +1,793 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::env; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use codex_protocol::mcp_protocol::AuthMode; + +use crate::token_data::TokenData; +use crate::token_data::parse_id_token; + +#[derive(Debug, Clone)] +pub struct CodexAuth { + pub mode: AuthMode, + + pub(crate) api_key: Option, + pub(crate) auth_dot_json: Arc>>, + pub(crate) auth_file: PathBuf, + pub(crate) client: reqwest::Client, +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode + } +} + +impl CodexAuth { + pub async fn refresh_token(&self) -> Result { + let token_data = self + .get_current_token_data() + .ok_or(std::io::Error::other("Token data is not available."))?; + let token = token_data.refresh_token; + + let refresh_response = try_refresh_token(token, &self.client) + .await + .map_err(std::io::Error::other)?; + + let updated = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + if let Ok(mut auth_lock) = self.auth_dot_json.lock() { + *auth_lock = Some(updated.clone()); + } + + let access = match updated.tokens { + Some(t) => t.access_token, + None => { + return Err(std::io::Error::other( + "Token data is not available after refresh.", + )); + } + }; + Ok(access) + } + + /// Loads the available auth information from the auth.json or + /// OPENAI_API_KEY environment variable. + pub fn from_codex_home( + codex_home: &Path, + preferred_auth_method: AuthMode, + originator: &str, + ) -> std::io::Result> { + load_auth(codex_home, true, preferred_auth_method, originator) + } + + pub async fn get_token_data(&self) -> Result { + let auth_dot_json: Option = self.get_current_auth_json(); + match auth_dot_json { + Some(AuthDotJson { + tokens: Some(mut tokens), + last_refresh: Some(last_refresh), + .. + }) => { + if last_refresh < Utc::now() - chrono::Duration::days(28) { + let refresh_response = tokio::time::timeout( + Duration::from_secs(60), + try_refresh_token(tokens.refresh_token.clone(), &self.client), + ) + .await + .map_err(|_| { + std::io::Error::other("timed out while refreshing OpenAI API key") + })? + .map_err(std::io::Error::other)?; + + let updated_auth_dot_json = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + tokens = updated_auth_dot_json + .tokens + .clone() + .ok_or(std::io::Error::other( + "Token data is not available after refresh.", + ))?; + + #[expect(clippy::unwrap_used)] + let mut auth_lock = self.auth_dot_json.lock().unwrap(); + *auth_lock = Some(updated_auth_dot_json); + } + + Ok(tokens) + } + _ => Err(std::io::Error::other("Token data is not available.")), + } + } + + pub async fn get_token(&self) -> Result { + match self.mode { + AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), + AuthMode::ChatGPT => { + let id_token = self.get_token_data().await?.access_token; + Ok(id_token) + } + } + } + + pub fn get_account_id(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.account_id.clone()) + } + + pub fn get_plan_type(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) + } + + fn get_current_auth_json(&self) -> Option { + #[expect(clippy::unwrap_used)] + self.auth_dot_json.lock().unwrap().clone() + } + + fn get_current_token_data(&self) -> Option { + self.get_current_auth_json().and_then(|t| t.tokens.clone()) + } + + /// Consider this private to integration tests. + pub fn create_dummy_chatgpt_auth_for_testing() -> Self { + let auth_dot_json = AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + + let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); + Self { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file: PathBuf::new(), + auth_dot_json, + client: crate::default_client::create_client("codex_cli_rs"), + } + } + + fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self { + Self { + api_key: Some(api_key.to_owned()), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + client, + } + } + + pub fn from_api_key(api_key: &str) -> Self { + Self::from_api_key_with_client( + api_key, + crate::default_client::create_client(crate::default_client::DEFAULT_ORIGINATOR), + ) + } +} + +pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +fn read_openai_api_key_from_env() -> Option { + env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .filter(|s| !s.is_empty()) +} + +pub fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` +/// if a file was removed, `Ok(false)` if no auth file was present. +pub fn logout(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +/// Writes an `auth.json` that contains only the API key. Intended for CLI use. +pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson { + openai_api_key: Some(api_key.to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(codex_home), &auth_dot_json) +} + +fn load_auth( + codex_home: &Path, + include_env_var: bool, + preferred_auth_method: AuthMode, + originator: &str, +) -> std::io::Result> { + // First, check to see if there is a valid auth.json file. If not, we fall + // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable + // (if it is set). + let auth_file = get_auth_file(codex_home); + let client = crate::default_client::create_client(originator); + let auth_dot_json = match try_read_auth_json(&auth_file) { + Ok(auth) => auth, + // If auth.json does not exist, try to read the OPENAI_API_KEY from the + // environment variable. + Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { + return match read_openai_api_key_from_env() { + Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))), + None => Ok(None), + }; + } + // Though if auth.json exists but is malformed, do not fall back to the + // env var because the user may be expecting to use AuthMode::ChatGPT. + Err(e) => { + return Err(e); + } + }; + + let AuthDotJson { + openai_api_key: auth_json_api_key, + tokens, + last_refresh, + } = auth_dot_json; + + // If the auth.json has an API key AND does not appear to be on a plan that + // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. + if let Some(api_key) = &auth_json_api_key { + // Should any of these be AuthMode::ChatGPT with the api_key set? + // Does AuthMode::ChatGPT indicate that there is an auth.json that is + // "refreshable" even if we are using the API key for auth? + match &tokens { + Some(tokens) => { + if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { + return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); + } else { + // Ignore the API key and fall through to ChatGPT auth. + } + } + None => { + // We have an API key but no tokens in the auth.json file. + // Perhaps the user ran `codex login --api-key ` or updated + // auth.json by hand. Either way, let's assume they are trying + // to use their API key. + return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); + } + } + } + + // For the AuthMode::ChatGPT variant, perhaps neither api_key nor + // openai_api_key should exist? + Ok(Some(CodexAuth { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file, + auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { + openai_api_key: None, + tokens, + last_refresh, + }))), + client, + })) +} + +/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. +/// Returns the full AuthDotJson structure after refreshing if necessary. +pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) +} + +pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) +} + +async fn update_tokens( + auth_file: &Path, + id_token: String, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut auth_dot_json = try_read_auth_json(auth_file)?; + + let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; + if let Some(access_token) = access_token { + tokens.access_token = access_token.to_string(); + } + if let Some(refresh_token) = refresh_token { + tokens.refresh_token = refresh_token.to_string(); + } + auth_dot_json.last_refresh = Some(Utc::now()); + write_auth_json(auth_file, &auth_dot_json)?; + Ok(auth_dot_json) +} + +async fn try_refresh_token( + refresh_token: String, + client: &reqwest::Client, +) -> std::io::Result { + let refresh_request = RefreshRequest { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + scope: "openid profile email", + }; + + // Use shared client factory to include standard headers + let response = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/json") + .json(&refresh_request) + .send() + .await + .map_err(std::io::Error::other)?; + + if response.status().is_success() { + let refresh_response = response + .json::() + .await + .map_err(std::io::Error::other)?; + Ok(refresh_response) + } else { + Err(std::io::Error::other(format!( + "Failed to refresh token: {}", + response.status() + ))) + } +} + +#[derive(Serialize)] +struct RefreshRequest { + client_id: &'static str, + grant_type: &'static str, + refresh_token: String, + scope: &'static str, +} + +#[derive(Deserialize, Clone)] +struct RefreshResponse { + id_token: String, + access_token: Option, + refresh_token: Option, +} + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, +} + +// Shared constant for token refresh (client id used for oauth token refresh flow) +pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +use std::sync::RwLock; + +/// Internal cached auth state. +#[derive(Clone, Debug)] +struct CachedAuth { + preferred_auth_mode: AuthMode, + auth: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_data::IdTokenInfo; + use crate::token_data::KnownPlan; + use crate::token_data::PlanType; + use base64::Engine; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::tempdir; + + const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; + + #[tokio::test] + async fn roundtrip_auth_dot_json() { + let codex_home = tempdir().unwrap(); + let _ = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let file = get_auth_file(codex_home.path()); + let auth_dot_json = try_read_auth_json(&file).unwrap(); + write_auth_json(&file, &auth_dot_json).unwrap(); + + let same_auth_dot_json = try_read_auth_json(&file).unwrap(); + assert_eq!(auth_dot_json, same_auth_dot_json); + } + + #[tokio::test] + async fn pro_account_with_no_api_key_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + .. + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs") + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in + /// [`TokenData::is_plan_that_should_use_api_key`], it should use + /// [`AuthMode::ChatGPT`]. + #[tokio::test] + async fn pro_account_with_api_key_still_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + .. + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs") + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise + /// account, then it should use [`AuthMode::ApiKey`]. + #[tokio::test] + async fn enterprise_account_with_api_key_uses_apikey_auth() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "enterprise".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + .. + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs") + .unwrap() + .unwrap(); + assert_eq!(Some("sk-test-key".to_string()), api_key); + assert_eq!(AuthMode::ApiKey, mode); + + let guard = auth_dot_json.lock().expect("should unwrap"); + assert!(guard.is_none(), "auth_dot_json should be None"); + } + + #[tokio::test] + async fn loads_api_key_from_auth_json() { + let dir = tempdir().unwrap(); + let auth_file = dir.path().join("auth.json"); + std::fs::write( + auth_file, + r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, + ) + .unwrap(); + + let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT, "codex_cli_rs") + .unwrap() + .unwrap(); + assert_eq!(auth.mode, AuthMode::ApiKey); + assert_eq!(auth.api_key, Some("sk-test-key".to_string())); + + assert!(auth.get_token_data().await.is_err()); + } + + #[test] + fn logout_removes_auth_file() -> Result<(), std::io::Error> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let removed = logout(dir.path())?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } + + struct AuthFileParams { + openai_api_key: Option, + chatgpt_plan_type: String, + } + + fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + // Create a minimal valid JWT for the id_token field. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": { + "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", + "chatgpt_plan_type": params.chatgpt_plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + } + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header)?); + let payload_b64 = b64(&serde_json::to_vec(&payload)?); + let signature_b64 = b64(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let auth_json_data = json!({ + "OPENAI_API_KEY": params.openai_api_key, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token" + }, + "last_refresh": LAST_REFRESH, + }); + let auth_json = serde_json::to_string_pretty(&auth_json_data)?; + std::fs::write(auth_file, auth_json)?; + Ok(fake_jwt) + } +} + +/// Central manager providing a single source of truth for auth.json derived +/// authentication data. It loads once (or on preference change) and then +/// hands out cloned `CodexAuth` values so the rest of the program has a +/// consistent snapshot. +/// +/// External modifications to `auth.json` will NOT be observed until +/// `reload()` is called explicitly. This matches the design goal of avoiding +/// different parts of the program seeing inconsistent auth data mid‑run. +#[derive(Debug)] +pub struct AuthManager { + codex_home: PathBuf, + originator: String, + inner: RwLock, +} + +impl AuthManager { + /// Create a new manager loading the initial auth using the provided + /// preferred auth method. Errors loading auth are swallowed; `auth()` will + /// simply return `None` in that case so callers can treat it as an + /// unauthenticated state. + pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self { + let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator) + .ok() + .flatten(); + Self { + codex_home, + originator, + inner: RwLock::new(CachedAuth { + preferred_auth_mode, + auth, + }), + } + } + + /// Create an AuthManager with a specific CodexAuth, for testing only. + pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { + let preferred_auth_mode = auth.mode; + let cached = CachedAuth { + preferred_auth_mode, + auth: Some(auth), + }; + Arc::new(Self { + codex_home: PathBuf::new(), + originator: "codex_cli_rs".to_string(), + inner: RwLock::new(cached), + }) + } + + /// Current cached auth (clone). May be `None` if not logged in or load failed. + pub fn auth(&self) -> Option { + self.inner.read().ok().and_then(|c| c.auth.clone()) + } + + /// Preferred auth method used when (re)loading. + pub fn preferred_auth_method(&self) -> AuthMode { + self.inner + .read() + .map(|c| c.preferred_auth_mode) + .unwrap_or(AuthMode::ApiKey) + } + + /// Force a reload using the existing preferred auth method. Returns + /// whether the auth value changed. + pub fn reload(&self) -> bool { + let preferred = self.preferred_auth_method(); + let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator) + .ok() + .flatten(); + if let Ok(mut guard) = self.inner.write() { + let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); + guard.auth = new_auth; + changed + } else { + false + } + } + + fn auths_equal(a: &Option, b: &Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a == b, + _ => false, + } + } + + /// Convenience constructor returning an `Arc` wrapper. + pub fn shared( + codex_home: PathBuf, + preferred_auth_mode: AuthMode, + originator: String, + ) -> Arc { + Arc::new(Self::new(codex_home, preferred_auth_mode, originator)) + } + + /// Attempt to refresh the current auth token (if any). On success, reload + /// the auth state from disk so other components observe refreshed token. + pub async fn refresh_token(&self) -> std::io::Result> { + let auth = match self.auth() { + Some(a) => a, + None => return Ok(None), + }; + match auth.refresh_token().await { + Ok(token) => { + // Reload to pick up persisted changes. + self.reload(); + Ok(Some(token)) + } + Err(e) => Err(e), + } + } + + /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) + /// if a file was removed, Ok(false) if no auth file existed. On success, + /// reloads the in‑memory auth cache so callers immediately observe the + /// unauthenticated state. + pub fn logout(&self) -> std::io::Result { + let removed = super::auth::logout(&self.codex_home)?; + // Always reload to clear any cached auth (even if file absent). + self.reload(); + Ok(removed) + } +} diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 6eca119f3e..fc8602de8e 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -43,7 +43,107 @@ pub(crate) async fn stream_chat_completions( let input = prompt.get_formatted_input(); + // Pre-scan: map Reasoning blocks to the adjacent assistant anchor after the last user. + // - If the last emitted message is a user message, drop all reasoning. + // - Otherwise, for each Reasoning item after the last user message, attach it + // to the immediate previous assistant message (stop turns) or the immediate + // next assistant anchor (tool-call turns: function/local shell call, or assistant message). + let mut reasoning_by_anchor_index: std::collections::HashMap = + std::collections::HashMap::new(); + + // Determine the last role that would be emitted to Chat Completions. + let mut last_emitted_role: Option<&str> = None; for item in &input { + match item { + ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()), + ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { + last_emitted_role = Some("assistant") + } + ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"), + ResponseItem::Reasoning { .. } | ResponseItem::Other => {} + ResponseItem::CustomToolCall { .. } => {} + ResponseItem::CustomToolCallOutput { .. } => {} + ResponseItem::WebSearchCall { .. } => {} + } + } + + // Find the last user message index in the input. + let mut last_user_index: Option = None; + for (idx, item) in input.iter().enumerate() { + if let ResponseItem::Message { role, .. } = item + && role == "user" + { + last_user_index = Some(idx); + } + } + + // Attach reasoning only if the conversation does not end with a user message. + if !matches!(last_emitted_role, Some("user")) { + for (idx, item) in input.iter().enumerate() { + // Only consider reasoning that appears after the last user message. + if let Some(u_idx) = last_user_index + && idx <= u_idx + { + continue; + } + + if let ResponseItem::Reasoning { + content: Some(items), + .. + } = item + { + let mut text = String::new(); + for c in items { + match c { + ReasoningItemContent::ReasoningText { text: t } + | ReasoningItemContent::Text { text: t } => text.push_str(t), + } + } + if text.trim().is_empty() { + continue; + } + + // Prefer immediate previous assistant message (stop turns) + let mut attached = false; + if idx > 0 + && let ResponseItem::Message { role, .. } = &input[idx - 1] + && role == "assistant" + { + reasoning_by_anchor_index + .entry(idx - 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + attached = true; + } + + // Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message) + if !attached && idx + 1 < input.len() { + match &input[idx + 1] { + ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { + reasoning_by_anchor_index + .entry(idx + 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + } + ResponseItem::Message { role, .. } if role == "assistant" => { + reasoning_by_anchor_index + .entry(idx + 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + } + _ => {} + } + } + } + } + } + + // Track last assistant text we emitted to avoid duplicate assistant messages + // in the outbound Chat Completions payload (can happen if a final + // aggregated assistant message was recorded alongside an earlier partial). + let mut last_assistant_text: Option = None; + + for (idx, item) in input.iter().enumerate() { match item { ResponseItem::Message { role, content, .. } => { let mut text = String::new(); @@ -56,7 +156,24 @@ pub(crate) async fn stream_chat_completions( _ => {} } } - messages.push(json!({"role": role, "content": text})); + // Skip exact-duplicate assistant messages. + if role == "assistant" { + if let Some(prev) = &last_assistant_text + && prev == &text + { + continue; + } + last_assistant_text = Some(text.clone()); + } + + let mut msg = json!({"role": role, "content": text}); + if role == "assistant" + && let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::FunctionCall { name, @@ -64,7 +181,7 @@ pub(crate) async fn stream_chat_completions( call_id, .. } => { - messages.push(json!({ + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ @@ -75,7 +192,13 @@ pub(crate) async fn stream_chat_completions( "arguments": arguments, } }] - })); + }); + if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::LocalShellCall { id, @@ -84,7 +207,7 @@ pub(crate) async fn stream_chat_completions( action, } => { // Confirm with API team. - messages.push(json!({ + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ @@ -93,7 +216,13 @@ pub(crate) async fn stream_chat_completions( "status": status, "action": action, }] - })); + }); + if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::FunctionCallOutput { call_id, output } => { messages.push(json!({ @@ -331,7 +460,10 @@ async fn process_chat_sse( // Some providers stream `reasoning` as a plain string while others // nest the text under an object (e.g. `{ "reasoning": { "text": "…" } }`). if let Some(reasoning_val) = choice.get("delta").and_then(|d| d.get("reasoning")) { - let mut maybe_text = reasoning_val.as_str().map(|s| s.to_string()); + let mut maybe_text = reasoning_val + .as_str() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); if maybe_text.is_none() && reasoning_val.is_object() { if let Some(s) = reasoning_val @@ -350,12 +482,39 @@ async fn process_chat_sse( } if let Some(reasoning) = maybe_text { + // Accumulate so we can emit a terminal Reasoning item at the end. + reasoning_text.push_str(&reasoning); let _ = tx_event .send(Ok(ResponseEvent::ReasoningContentDelta(reasoning))) .await; } } + // Some providers only include reasoning on the final message object. + if let Some(message_reasoning) = choice.get("message").and_then(|m| m.get("reasoning")) + { + // Accept either a plain string or an object with { text | content } + if let Some(s) = message_reasoning.as_str() { + if !s.is_empty() { + reasoning_text.push_str(s); + let _ = tx_event + .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) + .await; + } + } else if let Some(obj) = message_reasoning.as_object() + && let Some(s) = obj + .get("text") + .and_then(|v| v.as_str()) + .or_else(|| obj.get("content").and_then(|v| v.as_str())) + && !s.is_empty() + { + reasoning_text.push_str(s); + let _ = tx_event + .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) + .await; + } + } + // Handle streaming function / tool calls. if let Some(tool_calls) = choice .get("delta") @@ -511,27 +670,47 @@ where // do NOT emit yet. Forward any other item (e.g. FunctionCall) right // away so downstream consumers see it. - let is_assistant_delta = matches!(&item, codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant"); + let is_assistant_message = matches!( + &item, + codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant" + ); - if is_assistant_delta { - // Only use the final assistant message if we have not - // seen any deltas; otherwise, deltas already built the - // cumulative text and this would duplicate it. - if this.cumulative.is_empty() - && let codex_protocol::models::ResponseItem::Message { content, .. } = - &item - && let Some(text) = content.iter().find_map(|c| match c { - codex_protocol::models::ContentItem::OutputText { text } => { - Some(text) + if is_assistant_message { + match this.mode { + AggregateMode::AggregatedOnly => { + // Only use the final assistant message if we have not + // seen any deltas; otherwise, deltas already built the + // cumulative text and this would duplicate it. + if this.cumulative.is_empty() + && let codex_protocol::models::ResponseItem::Message { + content, + .. + } = &item + && let Some(text) = content.iter().find_map(|c| match c { + codex_protocol::models::ContentItem::OutputText { + text, + } => Some(text), + _ => None, + }) + { + this.cumulative.push_str(text); } - _ => None, - }) - { - this.cumulative.push_str(text); + // Swallow assistant message here; emit on Completed. + continue; + } + AggregateMode::Streaming => { + // In streaming mode, if we have not seen any deltas, forward + // the final assistant message directly. If deltas were seen, + // suppress the final message to avoid duplication. + if this.cumulative.is_empty() { + return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone( + item, + )))); + } else { + continue; + } + } } - - // Swallow assistant message here; emit on Completed. - continue; } // Not an assistant message – forward immediately. @@ -563,6 +742,11 @@ where emitted_any = true; } + // Always emit the final aggregated assistant message when any + // content deltas have been observed. In AggregatedOnly mode this + // is the sole assistant output; in Streaming mode this finalizes + // the streamed deltas into a terminal OutputItemDone so callers + // can persist/render the message once per turn. if !this.cumulative.is_empty() { let aggregated_message = codex_protocol::models::ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b7bfeaa7ca..0481767030 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,12 +1,14 @@ use std::io::BufRead; use std::path::Path; +use std::sync::OnceLock; use std::time::Duration; +use crate::AuthManager; use bytes::Bytes; -use codex_login::AuthManager; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use eventsource_stream::Eventsource; use futures::prelude::*; +use regex_lite::Regex; use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; @@ -28,6 +30,7 @@ use crate::client_common::ResponsesApiRequest; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; +use crate::default_client::create_client; use crate::error::CodexErr; use crate::error::Result; use crate::error::UsageLimitReachedError; @@ -38,7 +41,6 @@ use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::TokenUsage; -use crate::user_agent::get_codex_user_agent; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -53,6 +55,8 @@ struct ErrorResponse { #[derive(Debug, Deserialize)] struct Error { r#type: Option, + #[allow(dead_code)] + code: Option, message: Option, // Optional fields available on "usage_limit_reached" and "usage_not_included" errors @@ -80,10 +84,12 @@ impl ModelClient { summary: ReasoningSummaryConfig, session_id: Uuid, ) -> Self { + let client = create_client(&config.responses_originator_header); + Self { config, auth_manager, - client: reqwest::Client::new(), + client, provider, session_id, effort, @@ -238,10 +244,6 @@ impl ModelClient { req_builder = req_builder.header("chatgpt-account-id", account_id); } - let originator = &self.config.responses_originator_header; - req_builder = req_builder.header("originator", originator); - req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator))); - let res = req_builder.send().await; if let Ok(resp) = &res { trace!( @@ -564,8 +566,9 @@ async fn process_sse( if let Some(error) = error { match serde_json::from_value::(error.clone()) { Ok(error) => { + let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); - response_error = Some(CodexErr::Stream(message, None)); + response_error = Some(CodexErr::Stream(message, delay)); } Err(e) => { debug!("failed to parse ErrorResponse: {e}"); @@ -651,6 +654,40 @@ async fn stream_from_fixture( Ok(ResponseStream { rx_event }) } +fn rate_limit_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + + #[expect(clippy::unwrap_used)] + RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap()) +} + +fn try_parse_retry_after(err: &Error) -> Option { + if err.code != Some("rate_limit_exceeded".to_string()) { + return None; + } + + // parse the Please try again in 1.898s format using regex + let re = rate_limit_regex(); + if let Some(message) = &err.message + && let Some(captures) = re.captures(message) + { + let seconds = captures.get(1); + let unit = captures.get(2); + + if let (Some(value), Some(unit)) = (seconds, unit) { + let value = value.as_str().parse::().ok()?; + let unit = unit.as_str(); + + if unit == "s" { + return Some(Duration::from_secs_f64(value)); + } else if unit == "ms" { + return Some(Duration::from_millis(value as u64)); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -871,7 +908,7 @@ mod tests { msg, "Rate limit reached for gpt-5 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more." ); - assert_eq!(*delay, None); + assert_eq!(*delay, Some(Duration::from_secs_f64(11.054))); } other => panic!("unexpected second event: {other:?}"), } @@ -975,4 +1012,31 @@ mod tests { ); } } + + #[test] + fn test_try_parse_retry_after() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_in_seconds: None + }; + + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_millis(28))); + } + + #[test] + fn test_try_parse_retry_after_no_delay() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_in_seconds: None + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); + } } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2740105100..77f6a63a09 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -35,7 +35,7 @@ pub struct Prompt { /// Tools available to the model, including additional tools sourced from /// external MCP servers. - pub tools: Vec, + pub(crate) tools: Vec, /// Optional override for the built-in BASE_INSTRUCTIONS. pub base_instructions_override: Option, @@ -174,7 +174,7 @@ pub(crate) fn create_text_param_for_request( }) } -pub(crate) struct ResponseStream { +pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a34bc7608e..cf1ed5d148 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,12 +8,12 @@ use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::time::Duration; +use crate::AuthManager; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; -use codex_login::AuthManager; use codex_protocol::protocol::ConversationHistoryResponseEvent; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; @@ -46,6 +46,7 @@ use crate::config_edit_tool::handle_set_config; use crate::config_edit_tool::handle_show_codex_docs; use crate::config_types::ShellEnvironmentPolicy; use crate::conversation_history::ConversationHistory; +use crate::conversation_manager::InitialHistory; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -172,7 +173,7 @@ impl Codex { pub async fn spawn( config: Config, auth_manager: Arc, - initial_history: Option>, + conversation_history: InitialHistory, ) -> CodexResult { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -180,7 +181,6 @@ impl Codex { let user_instructions = get_user_instructions(&config).await; let config = Arc::new(config); - let resume_path = config.experimental_resume.clone(); let configure_session = ConfigureSession { provider: config.model_provider.clone(), @@ -194,7 +194,6 @@ impl Codex { disable_response_storage: config.disable_response_storage, notify: config.notify.clone(), cwd: config.cwd.clone(), - resume_path, }; // Generate a unique ID for the lifetime of this Codex session. @@ -203,13 +202,15 @@ impl Codex { config.clone(), auth_manager.clone(), tx_event.clone(), - initial_history, ) .await .map_err(|e| { error!("Failed to create session: {e:#}"); CodexErr::InternalAgentDied })?; + session + .record_initial_history(&turn_context, conversation_history) + .await; let session_id = session.session_id; // This task will run until Op::Shutdown is received. @@ -355,8 +356,6 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, - - resume_path: Option, } impl Session { @@ -365,8 +364,8 @@ impl Session { config: Arc, auth_manager: Arc, tx_event: Sender, - initial_history: Option>, ) -> anyhow::Result<(Arc, TurnContext)> { + let session_id = Uuid::new_v4(); let ConfigureSession { provider, model, @@ -379,7 +378,6 @@ impl Session { disable_response_storage, notify, cwd, - resume_path, } = configure_session; debug!("Configuring session: model={model}; provider={provider:?}"); if !cwd.is_absolute() { @@ -395,89 +393,25 @@ impl Session { // - spin up MCP connection manager // - perform default shell discovery // - load history metadata - let rollout_fut = async { - match resume_path.as_ref() { - Some(path) => RolloutRecorder::resume(path, cwd.clone()) - .await - .map(|(rec, saved)| (saved.session_id, Some(saved), rec)), - None => { - let session_id = Uuid::new_v4(); - RolloutRecorder::new(&config, session_id, user_instructions.clone()) - .await - .map(|rec| (session_id, None, rec)) - } - } - }; + let rollout_fut = RolloutRecorder::new(&config, session_id, user_instructions.clone()); let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone()); let default_shell_fut = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); // Join all independent futures. - let (rollout_res, mcp_res, default_shell, (history_log_id, history_entry_count)) = + let (rollout_recorder, mcp_res, default_shell, (history_log_id, history_entry_count)) = tokio::join!(rollout_fut, mcp_fut, default_shell_fut, history_meta_fut); - // Handle rollout result, which determines the session_id. - struct RolloutResult { - session_id: Uuid, - rollout_recorder: Option, - restored_items: Option>, - } - let rollout_result = match rollout_res { - Ok((session_id, maybe_saved, recorder)) => { - let restored_items: Option> = initial_history.or_else(|| { - maybe_saved.and_then(|saved_session| { - if saved_session.items.is_empty() { - None - } else { - Some(saved_session.items) - } - }) - }); - RolloutResult { - session_id, - rollout_recorder: Some(recorder), - restored_items, - } - } - Err(e) => { - if let Some(path) = resume_path.as_ref() { - return Err(anyhow::anyhow!( - "failed to resume rollout from {path:?}: {e}" - )); - } - - let message = format!("failed to initialize rollout recorder: {e}"); - post_session_configured_error_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { - message: message.clone(), - }), - }); - warn!("{message}"); - - RolloutResult { - session_id: Uuid::new_v4(), - rollout_recorder: None, - restored_items: None, - } - } - }; - - let RolloutResult { - session_id, - rollout_recorder, - restored_items, - } = rollout_result; - + let rollout_recorder = rollout_recorder.map_err(|e| { + error!("failed to initialize rollout recorder: {e:#}"); + anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") + })?; // Create the mutable state for the Session. - let mut state = State { + let state = State { history: ConversationHistory::new(), ..Default::default() }; - if let Some(restored_items) = restored_items { - state.history.record_items(&restored_items); - } // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -542,26 +476,12 @@ impl Session { session_manager: ExecSessionManager::default(), notify, state: Mutex::new(state), - rollout: Mutex::new(rollout_recorder), + rollout: Mutex::new(Some(rollout_recorder)), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, }); - // record the initial user instructions and environment context, - // regardless of whether we restored items. - let mut conversation_items = Vec::::with_capacity(2); - if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); - } - conversation_items.push(ResponseItem::from(EnvironmentContext::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - ))); - sess.record_conversation_items(&conversation_items).await; - // Dispatch the SessionConfiguredEvent first and then report any errors. let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -599,6 +519,42 @@ impl Session { } } + async fn record_initial_history( + &self, + turn_context: &TurnContext, + conversation_history: InitialHistory, + ) { + match conversation_history { + InitialHistory::New => { + self.record_initial_history_new(turn_context).await; + } + InitialHistory::Resumed(items) => { + self.record_initial_history_resumed(items).await; + } + } + } + + async fn record_initial_history_new(&self, turn_context: &TurnContext) { + // record the initial user instructions and environment context, + // regardless of whether we restored items. + // TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages. + let mut conversation_items = Vec::::with_capacity(2); + if let Some(user_instructions) = turn_context.user_instructions.as_deref() { + conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); + } + conversation_items.push(ResponseItem::from(EnvironmentContext::new( + Some(turn_context.cwd.clone()), + Some(turn_context.approval_policy), + Some(turn_context.sandbox_policy.clone()), + Some(self.user_shell.clone()), + ))); + self.record_conversation_items(&conversation_items).await; + } + + async fn record_initial_history_resumed(&self, items: Vec) { + self.record_conversation_items(&items).await; + } + /// Sends the given event to the client and swallows the send event, if /// any, logging it as an error. pub(crate) async fn send_event(&self, event: Event) { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 7845f5d4ce..6a3f39b652 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -15,10 +15,10 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; -use codex_login::AuthMode; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use dirs::home_dir; use serde::Deserialize; use std::collections::HashMap; @@ -185,6 +185,8 @@ pub struct Config { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: bool, + + pub use_experimental_reasoning_summary: bool, } impl Config { @@ -482,6 +484,8 @@ pub struct ConfigToml { pub experimental_use_exec_command_tool: Option, + pub use_experimental_reasoning_summary: Option, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header_internal_override: Option, @@ -807,6 +811,9 @@ impl Config { .unwrap_or(false), include_view_image_tool, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + use_experimental_reasoning_summary: cfg + .use_experimental_reasoning_summary + .unwrap_or(false), }; Ok(config) } @@ -1177,6 +1184,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }, o3_profile_config ); @@ -1235,6 +1243,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1308,6 +1317,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index ae169ed56f..78674cd7e7 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; -use codex_login::AuthManager; -use codex_login::CodexAuth; +use crate::AuthManager; +use crate::CodexAuth; use tokio::sync::RwLock; use uuid::Uuid; @@ -16,8 +17,15 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; +use crate::rollout::RolloutRecorder; use codex_protocol::models::ResponseItem; +#[derive(Debug, Clone, PartialEq)] +pub enum InitialHistory { + New, + Resumed(Vec), +} + /// Represents a newly created Codex conversation, including the first event /// (which is [`EventMsg::SessionConfigured`]). pub struct NewConversation { @@ -44,7 +52,7 @@ impl ConversationManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { - Self::new(codex_login::AuthManager::from_auth_for_testing(auth)) + Self::new(crate::AuthManager::from_auth_for_testing(auth)) } pub async fn new_conversation(&self, config: Config) -> CodexResult { @@ -57,14 +65,21 @@ impl ConversationManager { config: Config, auth_manager: Arc, ) -> CodexResult { - let CodexSpawnOk { - codex, - session_id: conversation_id, - } = { - let initial_history = None; - Codex::spawn(config, auth_manager, initial_history).await? - }; - self.finalize_spawn(codex, conversation_id).await + // TO BE REFACTORED: use the config experimental_resume field until we have a mainstream way. + if let Some(resume_path) = config.experimental_resume.as_ref() { + let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?; + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = Codex::spawn(config, auth_manager, initial_history).await?; + self.finalize_spawn(codex, conversation_id).await + } else { + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = { Codex::spawn(config, auth_manager, InitialHistory::New).await? }; + self.finalize_spawn(codex, conversation_id).await + } } async fn finalize_spawn( @@ -110,6 +125,20 @@ impl ConversationManager { .ok_or_else(|| CodexErr::ConversationNotFound(conversation_id)) } + pub async fn resume_conversation_from_rollout( + &self, + config: Config, + rollout_path: PathBuf, + auth_manager: Arc, + ) -> CodexResult { + let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = Codex::spawn(config, auth_manager, initial_history).await?; + self.finalize_spawn(codex, conversation_id).await + } + pub async fn remove_conversation(&self, conversation_id: Uuid) { self.conversations.write().await.remove(&conversation_id); } @@ -125,7 +154,7 @@ impl ConversationManager { config: Config, ) -> CodexResult { // Compute the prefix up to the cut point. - let truncated_history = + let history = truncate_after_dropping_last_messages(conversation_history, num_messages_to_drop); // Spawn a new conversation with the computed initial history. @@ -133,7 +162,7 @@ impl ConversationManager { let CodexSpawnOk { codex, session_id: conversation_id, - } = Codex::spawn(config, auth_manager, Some(truncated_history)).await?; + } = Codex::spawn(config, auth_manager, history).await?; self.finalize_spawn(codex, conversation_id).await } @@ -141,9 +170,9 @@ impl ConversationManager { /// Return a prefix of `items` obtained by dropping the last `n` user messages /// and all items that follow them. -fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> Vec { - if n == 0 || items.is_empty() { - return items; +fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> InitialHistory { + if n == 0 { + return InitialHistory::Resumed(items); } // Walk backwards counting only `user` Message items, find cut index. @@ -161,11 +190,11 @@ fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> } } } - if count < n { - // If fewer than n messages exist, drop everything. - Vec::new() + if cut_index == 0 { + // No prefix remains after dropping; start a new conversation. + InitialHistory::New } else { - items.into_iter().take(cut_index).collect() + InitialHistory::Resumed(items.into_iter().take(cut_index).collect()) } } @@ -223,10 +252,10 @@ mod tests { let truncated = truncate_after_dropping_last_messages(items.clone(), 1); assert_eq!( truncated, - vec![items[0].clone(), items[1].clone(), items[2].clone()] + InitialHistory::Resumed(vec![items[0].clone(), items[1].clone(), items[2].clone(),]) ); let truncated2 = truncate_after_dropping_last_messages(items, 2); - assert!(truncated2.is_empty()); + assert_eq!(truncated2, InitialHistory::New); } } diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs new file mode 100644 index 0000000000..d274351779 --- /dev/null +++ b/codex-rs/core/src/default_client.rs @@ -0,0 +1,106 @@ +pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; + +pub fn get_codex_user_agent(originator: Option<&str>) -> String { + let build_version = env!("CARGO_PKG_VERSION"); + let os_info = os_info::get(); + format!( + "{}/{build_version} ({} {}; {}) {}", + originator.unwrap_or(DEFAULT_ORIGINATOR), + os_info.os_type(), + os_info.version(), + os_info.architecture().unwrap_or("unknown"), + crate::terminal::user_agent() + ) +} + +/// Create a reqwest client with default `originator` and `User-Agent` headers set. +pub fn create_client(originator: &str) -> reqwest::Client { + use reqwest::header::HeaderMap; + use reqwest::header::HeaderValue; + + let mut headers = HeaderMap::new(); + let originator_value = HeaderValue::from_str(originator) + .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_ORIGINATOR)); + headers.insert("originator", originator_value); + let ua = get_codex_user_agent(Some(originator)); + + match reqwest::Client::builder() + // Set UA via dedicated helper to avoid header validation pitfalls + .user_agent(ua) + .default_headers(headers) + .build() + { + Ok(client) => client, + Err(_) => reqwest::Client::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_codex_user_agent() { + let user_agent = get_codex_user_agent(None); + assert!(user_agent.starts_with("codex_cli_rs/")); + } + + #[tokio::test] + async fn test_create_client_sets_default_headers() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let originator = "test_originator"; + let client = create_client(originator); + + // Spin up a local mock server and capture a request. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let resp = client + .get(server.uri()) + .send() + .await + .expect("failed to send request"); + assert!(resp.status().is_success()); + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + let headers = &requests[0].headers; + + // originator header is set to the provided value + let originator_header = headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator_header.to_str().unwrap(), originator); + + // User-Agent matches the computed Codex UA for that originator + let expected_ua = get_codex_user_agent(Some(originator)); + let ua_header = headers + .get("user-agent") + .expect("user-agent header missing"); + assert_eq!(ua_header.to_str().unwrap(), expected_ua); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_macos() { + use regex_lite::Regex; + let user_agent = get_codex_user_agent(None); + let re = Regex::new( + r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", + ) + .unwrap(); + assert!(re.is_match(&user_agent)); + } +} diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 0290323cbe..79a8647a2b 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -10,7 +10,35 @@ use tokio::process::Command; use tokio::time::Duration as TokioDuration; use tokio::time::timeout; -use crate::util::is_inside_git_repo; +/// Return `true` if the project folder specified by the `Config` is inside a +/// Git repository. +/// +/// The check walks up the directory hierarchy looking for a `.git` file or +/// directory (note `.git` can be a file that contains a `gitdir` entry). This +/// approach does **not** require the `git` binary or the `git2` crate and is +/// therefore fairly lightweight. +/// +/// Note that this does **not** detect *work‑trees* created with +/// `git worktree add` where the checkout lives outside the main repository +/// directory. If you need Codex to work from such a checkout simply pass the +/// `--allow-no-git-exec` CLI flag that disables the repo requirement. +pub fn get_git_repo_root(base_dir: &Path) -> Option { + let mut dir = base_dir.to_path_buf(); + + loop { + if dir.join(".git").exists() { + return Some(dir); + } + + // Pop one component (go up one directory). `pop` returns false when + // we have reached the filesystem root. + if !dir.pop() { + break; + } + } + + None +} /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); @@ -94,9 +122,7 @@ pub async fn collect_git_info(cwd: &Path) -> Option { /// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha. pub async fn git_diff_to_remote(cwd: &Path) -> Option { - if !is_inside_git_repo(cwd) { - return None; - } + get_git_repo_root(cwd)?; let remotes = get_git_remotes(cwd).await?; let branches = branch_ancestry(cwd).await?; @@ -440,7 +466,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { } /// Resolve the path that should be used for trust checks. Similar to -/// `[utils::is_inside_git_repo]`, but resolves to the root of the main +/// `[get_git_repo_root]`, but resolves to the root of the main /// repository. Handles worktrees. pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { let base = if cwd.is_dir() { cwd } else { cwd.parent()? }; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9563ddc463..fcdfd72d21 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -6,12 +6,14 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod apply_patch; +pub mod auth; mod bash; mod chat_completions; mod client; mod client_common; pub mod codex; mod codex_conversation; +pub mod token_data; pub use codex_conversation::CodexConversation; pub mod config; pub mod config_profile; @@ -41,6 +43,10 @@ mod conversation_manager; pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; mod config_edit_tool; +// Re-export common auth types for workspace consumers +pub use auth::AuthManager; +pub use auth::CodexAuth; +pub mod default_client; pub mod model_family; mod openai_model_info; mod openai_tools; @@ -54,7 +60,7 @@ pub mod spawn; pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; -pub mod user_agent; +pub use rollout::list::ConversationsPage; mod user_notification; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; @@ -65,3 +71,14 @@ pub use codex_protocol::protocol; // Re-export protocol config enums to ensure call sites can use the same types // as those in the protocol crate when constructing protocol messages. pub use codex_protocol::config_types as protocol_config_types; + +pub use client::ModelClient; +pub use client_common::Prompt; +pub use client_common::ResponseEvent; +pub use client_common::ResponseStream; +pub use codex_protocol::models::ContentItem; +pub use codex_protocol::models::LocalShellAction; +pub use codex_protocol::models::LocalShellExecAction; +pub use codex_protocol::models::LocalShellStatus; +pub use codex_protocol::models::ReasoningItemContent; +pub use codex_protocol::models::ResponseItem; diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index 9f13ababb7..75bc5aa75c 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -105,47 +105,34 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config) // Ensure permissions. ensure_owner_only_permissions(&history_file).await?; - // Lock file. - acquire_exclusive_lock_with_retry(&history_file).await?; - - // We use sync I/O with spawn_blocking() because we are using a - // [`std::fs::File`] instead of a [`tokio::fs::File`] to leverage an - // advisory file locking API that is not available in the async API. + // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { - history_file.write_all(line.as_bytes())?; - history_file.flush()?; - Ok(()) + // Retry a few times to avoid indefinite blocking when contended. + for _ in 0..MAX_RETRIES { + match history_file.try_lock() { + Ok(()) => { + // While holding the exclusive lock, write the full line. + history_file.write_all(line.as_bytes())?; + history_file.flush()?; + return Ok(()); + } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(RETRY_SLEEP); + } + Err(e) => return Err(e.into()), + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "could not acquire exclusive lock on history file after multiple attempts", + )) }) .await??; Ok(()) } -/// Attempt to acquire an exclusive advisory lock on `file`, retrying up to 10 -/// times if the lock is currently held by another process. This prevents a -/// potential indefinite wait while still giving other writers some time to -/// finish their operation. -async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> { - use tokio::time::sleep; - - for _ in 0..MAX_RETRIES { - match file.try_lock() { - Ok(()) => return Ok(()), - Err(e) => match e { - std::fs::TryLockError::WouldBlock => { - sleep(RETRY_SLEEP).await; - } - other => return Err(other.into()), - }, - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "could not acquire exclusive lock on history file after multiple attempts", - )) -} - /// Asynchronously fetch the history file's *identifier* (inode on Unix) and /// the current number of entries by counting newline characters. pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) { @@ -221,29 +208,42 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option l, - Err(e) => { - tracing::warn!(error = %e, "failed to read line from history file"); + match lock_result { + Ok(()) => { + let reader = BufReader::new(&file); + for (idx, line_res) in reader.lines().enumerate() { + let line = match line_res { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = %e, "failed to read line from history file"); + return None; + } + }; + + if idx == offset { + match serde_json::from_str::(&line) { + Ok(entry) => return Some(entry), + Err(e) => { + tracing::warn!(error = %e, "failed to parse history entry"); + return None; + } + } + } + } + // Not found at requested offset. return None; } - }; - - if idx == offset { - match serde_json::from_str::(&line) { - Ok(entry) => return Some(entry), - Err(e) => { - tracing::warn!(error = %e, "failed to parse history entry"); - return None; - } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(RETRY_SLEEP); + } + Err(e) => { + tracing::warn!(error = %e, "failed to acquire shared lock on history file"); + return None; } } } @@ -258,26 +258,6 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option Result<()> { - for _ in 0..MAX_RETRIES { - match file.try_lock_shared() { - Ok(()) => return Ok(()), - Err(e) => match e { - std::fs::TryLockError::WouldBlock => { - std::thread::sleep(RETRY_SLEEP); - } - other => return Err(other.into()), - }, - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "could not acquire shared lock on history file after multiple attempts", - )) -} - /// On Unix systems ensure the file permissions are `0o600` (rw-------). If the /// permissions cannot be changed the error is propagated to the caller. #[cfg(unix)] diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 1a3e5a2767..c7da1194ec 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -5,8 +5,8 @@ //! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers` //! key. These override or extend the defaults at runtime. -use codex_login::AuthMode; -use codex_login::CodexAuth; +use crate::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 75633414f5..10e6f69281 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -52,7 +52,7 @@ pub(crate) enum OpenAiTool { LocalShell {}, // TODO: Understand why we get an error on web_search although the API docs say it's supported. // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C - #[serde(rename = "web_search_preview")] + #[serde(rename = "web_search")] WebSearch {}, #[serde(rename = "custom")] Freeform(FreeformTool), @@ -243,15 +243,17 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { let description = match sandbox_policy { SandboxPolicy::WorkspaceWrite { network_access, + writable_roots, .. } => { format!( r#" The shell tool is used to execute shell commands. -- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges: +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges: - Types of actions that require escalated privileges: - - Reading files outside the current directory - - Writing files outside the current directory, and protected folders like .git or .env{} + - Writing files other than those in the writable roots + - writable roots: +{}{} - Examples of commands that require escalated privileges: - git commit - npm install or pnpm install @@ -260,8 +262,9 @@ The shell tool is used to execute shell commands. - When invoking a command that will require escalated privileges: - Provide the with_escalated_permissions parameter with the boolean value true - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#, + writable_roots.iter().map(|wr| format!(" - {}", wr.to_string_lossy())).collect::>().join("\n"), if !network_access { - "\n - Commands that require network access\n" + "\n - Commands that require network access\n" } else { "" } @@ -273,9 +276,8 @@ The shell tool is used to execute shell commands. SandboxPolicy::ReadOnly => { r#" The shell tool is used to execute shell commands. -- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions: +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions: - Types of actions that require escalated privileges: - - Reading files outside the current directory - Writing files - Applying patches - Examples of commands that require escalated privileges: @@ -1146,4 +1148,84 @@ mod tests { }) ); } + + #[test] + fn test_shell_tool_for_sandbox_workspace_write() { + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec!["workspace".into()], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let tool = super::create_shell_tool_for_sandbox(&sandbox_policy); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + let expected = r#" +The shell tool is used to execute shell commands. +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges: + - Types of actions that require escalated privileges: + - Writing files other than those in the writable roots + - writable roots: + - workspace + - Commands that require network access + + - Examples of commands that require escalated privileges: + - git commit + - npm install or pnpm install + - cargo build + - cargo test +- When invoking a command that will require escalated privileges: + - Provide the with_escalated_permissions parameter with the boolean value true + - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#; + assert_eq!(description, expected); + } + + #[test] + fn test_shell_tool_for_sandbox_readonly() { + let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + let expected = r#" +The shell tool is used to execute shell commands. +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions: + - Types of actions that require escalated privileges: + - Writing files + - Applying patches + - Examples of commands that require escalated privileges: + - apply_patch + - git commit + - npm install or pnpm install + - cargo build + - cargo test +- When invoking a command that will require escalated privileges: + - Provide the with_escalated_permissions parameter with the boolean value true + - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#; + assert_eq!(description, expected); + } + + #[test] + fn test_shell_tool_for_sandbox_danger_full_access() { + let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + assert_eq!(description, "Runs a shell command and returns its output."); + } } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 7348af43ac..15dd2ff411 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -20,22 +20,6 @@ pub enum ParsedCommand { query: Option, path: Option, }, - Format { - cmd: String, - tool: Option, - targets: Option>, - }, - Test { - cmd: String, - }, - Lint { - cmd: String, - tool: Option, - targets: Option>, - }, - Noop { - cmd: String, - }, Unknown { cmd: String, }, @@ -50,10 +34,6 @@ impl From for codex_protocol::parse_command::ParsedCommand { ParsedCommand::Read { cmd, name } => P::Read { cmd, name }, ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path }, ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path }, - ParsedCommand::Format { cmd, tool, targets } => P::Format { cmd, tool, targets }, - ParsedCommand::Test { cmd } => P::Test { cmd }, - ParsedCommand::Lint { cmd, tool, targets } => P::Lint { cmd, tool, targets }, - ParsedCommand::Noop { cmd } => P::Noop { cmd }, ParsedCommand::Unknown { cmd } => P::Unknown { cmd }, } } @@ -122,7 +102,7 @@ mod tests { assert_parsed( &vec_str(&["bash", "-lc", inner]), vec![ParsedCommand::Unknown { - cmd: "git status | wc -l".to_string(), + cmd: "git status".to_string(), }], ); } @@ -244,6 +224,39 @@ mod tests { ); } + #[test] + fn cd_then_cat_is_single_read() { + assert_parsed( + &shlex_split_safe("cd foo && cat foo.txt"), + vec![ParsedCommand::Read { + cmd: "cat foo.txt".to_string(), + name: "foo.txt".to_string(), + }], + ); + } + + #[test] + fn bash_cd_then_bar_is_same_as_bar() { + // Ensure a leading `cd` inside bash -lc is dropped when followed by another command. + assert_parsed( + &shlex_split_safe("bash -lc 'cd foo && bar'"), + vec![ParsedCommand::Unknown { + cmd: "bar".to_string(), + }], + ); + } + + #[test] + fn bash_cd_then_cat_is_read() { + assert_parsed( + &shlex_split_safe("bash -lc 'cd foo && cat foo.txt'"), + vec![ParsedCommand::Read { + cmd: "cat foo.txt".to_string(), + name: "foo.txt".to_string(), + }], + ); + } + #[test] fn supports_ls_with_pipe() { let inner = "ls -la | sed -n '1,120p'"; @@ -315,27 +328,6 @@ mod tests { ); } - #[test] - fn supports_npm_run_with_forwarded_args() { - assert_parsed( - &vec_str(&[ - "npm", - "run", - "lint", - "--", - "--max-warnings", - "0", - "--format", - "json", - ]), - vec![ParsedCommand::Lint { - cmd: "npm run lint -- --max-warnings 0 --format json".to_string(), - tool: Some("npm-script:lint".to_string()), - targets: None, - }], - ); - } - #[test] fn supports_grep_recursive_current_dir() { assert_parsed( @@ -396,173 +388,10 @@ mod tests { fn supports_cd_and_rg_files() { assert_parsed( &shlex_split_safe("cd codex-rs && rg --files"), - vec![ - ParsedCommand::Unknown { - cmd: "cd codex-rs".to_string(), - }, - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ], - ); - } - - #[test] - fn echo_then_cargo_test_sequence() { - assert_parsed( - &shlex_split_safe("echo Running tests... && cargo test --all-features --quiet"), - vec![ParsedCommand::Test { - cmd: "cargo test --all-features --quiet".to_string(), - }], - ); - } - - #[test] - fn supports_cargo_fmt_and_test_with_config() { - assert_parsed( - &shlex_split_safe( - "cargo fmt -- --config imports_granularity=Item && cargo test -p core --all-features", - ), - vec![ - ParsedCommand::Format { - cmd: "cargo fmt -- --config 'imports_granularity=Item'".to_string(), - tool: Some("cargo fmt".to_string()), - targets: None, - }, - ParsedCommand::Test { - cmd: "cargo test -p core --all-features".to_string(), - }, - ], - ); - } - - #[test] - fn recognizes_rustfmt_and_clippy() { - assert_parsed( - &shlex_split_safe("rustfmt src/main.rs"), - vec![ParsedCommand::Format { - cmd: "rustfmt src/main.rs".to_string(), - tool: Some("rustfmt".to_string()), - targets: Some(vec!["src/main.rs".to_string()]), - }], - ); - - assert_parsed( - &shlex_split_safe("cargo clippy -p core --all-features -- -D warnings"), - vec![ParsedCommand::Lint { - cmd: "cargo clippy -p core --all-features -- -D warnings".to_string(), - tool: Some("cargo clippy".to_string()), - targets: None, - }], - ); - } - - #[test] - fn recognizes_pytest_go_and_tools() { - assert_parsed( - &shlex_split_safe( - "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok", - ), - vec![ParsedCommand::Test { - cmd: "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok" - .to_string(), - }], - ); - - assert_parsed( - &shlex_split_safe("go fmt ./..."), - vec![ParsedCommand::Format { - cmd: "go fmt ./...".to_string(), - tool: Some("go fmt".to_string()), - targets: Some(vec!["./...".to_string()]), - }], - ); - - assert_parsed( - &shlex_split_safe("go test ./pkg -run TestThing"), - vec![ParsedCommand::Test { - cmd: "go test ./pkg -run TestThing".to_string(), - }], - ); - - assert_parsed( - &shlex_split_safe("eslint . --max-warnings 0"), - vec![ParsedCommand::Lint { - cmd: "eslint . --max-warnings 0".to_string(), - tool: Some("eslint".to_string()), - targets: Some(vec![".".to_string()]), - }], - ); - - assert_parsed( - &shlex_split_safe("prettier -w ."), - vec![ParsedCommand::Format { - cmd: "prettier -w .".to_string(), - tool: Some("prettier".to_string()), - targets: Some(vec![".".to_string()]), - }], - ); - } - - #[test] - fn recognizes_jest_and_vitest_filters() { - assert_parsed( - &shlex_split_safe("jest -t 'should work' src/foo.test.ts"), - vec![ParsedCommand::Test { - cmd: "jest -t 'should work' src/foo.test.ts".to_string(), - }], - ); - - assert_parsed( - &shlex_split_safe("vitest -t 'runs' src/foo.test.tsx"), - vec![ParsedCommand::Test { - cmd: "vitest -t runs src/foo.test.tsx".to_string(), - }], - ); - } - - #[test] - fn recognizes_npx_and_scripts() { - assert_parsed( - &shlex_split_safe("npx eslint src"), - vec![ParsedCommand::Lint { - cmd: "npx eslint src".to_string(), - tool: Some("eslint".to_string()), - targets: Some(vec!["src".to_string()]), - }], - ); - - assert_parsed( - &shlex_split_safe("npx prettier -c ."), - vec![ParsedCommand::Format { - cmd: "npx prettier -c .".to_string(), - tool: Some("prettier".to_string()), - targets: Some(vec![".".to_string()]), - }], - ); - - assert_parsed( - &shlex_split_safe("pnpm run lint -- --max-warnings 0"), - vec![ParsedCommand::Lint { - cmd: "pnpm run lint -- --max-warnings 0".to_string(), - tool: Some("pnpm-script:lint".to_string()), - targets: None, - }], - ); - - assert_parsed( - &shlex_split_safe("npm test"), - vec![ParsedCommand::Test { - cmd: "npm test".to_string(), - }], - ); - - assert_parsed( - &shlex_split_safe("yarn test"), - vec![ParsedCommand::Test { - cmd: "yarn test".to_string(), + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, }], ); } @@ -770,6 +599,51 @@ mod tests { ); } + #[test] + fn parses_mixed_sequence_with_pipes_semicolons_and_or() { + // Provided long command sequence combining sequencing, pipelines, and ORs. + let inner = "pwd; ls -la; rg --files -g '!target' | wc -l; rg -n '^\\[workspace\\]' -n Cargo.toml || true; rg -n '^\\[package\\]' -n */Cargo.toml || true; cargo --version; rustc --version; cargo clippy --workspace --all-targets --all-features -q"; + let args = vec_str(&["bash", "-lc", inner]); + + let expected = vec![ + ParsedCommand::Unknown { + cmd: "pwd".to_string(), + }, + ParsedCommand::ListFiles { + cmd: shlex_join(&shlex_split_safe("ls -la")), + path: None, + }, + ParsedCommand::Search { + cmd: shlex_join(&shlex_split_safe("rg --files -g '!target'")), + query: None, + path: Some("!target".to_string()), + }, + ParsedCommand::Search { + cmd: shlex_join(&shlex_split_safe("rg -n '^\\[workspace\\]' -n Cargo.toml")), + query: Some("^\\[workspace\\]".to_string()), + path: Some("Cargo.toml".to_string()), + }, + ParsedCommand::Search { + cmd: shlex_join(&shlex_split_safe("rg -n '^\\[package\\]' -n */Cargo.toml")), + query: Some("^\\[package\\]".to_string()), + path: Some("Cargo.toml".to_string()), + }, + ParsedCommand::Unknown { + cmd: shlex_join(&shlex_split_safe("cargo --version")), + }, + ParsedCommand::Unknown { + cmd: shlex_join(&shlex_split_safe("rustc --version")), + }, + ParsedCommand::Unknown { + cmd: shlex_join(&shlex_split_safe( + "cargo clippy --workspace --all-targets --all-features -q", + )), + }, + ]; + + assert_parsed(&args, expected); + } + #[test] fn strips_true_in_sequence() { // `true` should be dropped from parsed sequences @@ -867,159 +741,6 @@ mod tests { ); } - #[test] - fn pnpm_test_is_parsed_as_test() { - assert_parsed( - &shlex_split_safe("pnpm test"), - vec![ParsedCommand::Test { - cmd: "pnpm test".to_string(), - }], - ); - } - - #[test] - fn pnpm_exec_vitest_is_unknown() { - // From commands_combined: cd codex-cli && pnpm exec vitest run tests/... --threads=false --passWithNoTests - let inner = "cd codex-cli && pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests"; - assert_parsed( - &shlex_split_safe(inner), - vec![ - ParsedCommand::Unknown { - cmd: "cd codex-cli".to_string(), - }, - ParsedCommand::Unknown { - cmd: "pnpm exec vitest run tests/file-tag-utils.test.ts '--threads=false' --passWithNoTests".to_string(), - }, - ], - ); - } - - #[test] - fn cargo_test_with_crate() { - assert_parsed( - &shlex_split_safe("cargo test -p codex-core parse_command::"), - vec![ParsedCommand::Test { - cmd: "cargo test -p codex-core parse_command::".to_string(), - }], - ); - } - - #[test] - fn cargo_test_with_crate_2() { - assert_parsed( - &shlex_split_safe( - "cd core && cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants", - ), - vec![ParsedCommand::Test { - cmd: "cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants".to_string(), - }], - ); - } - - #[test] - fn cargo_test_with_crate_3() { - assert_parsed( - &shlex_split_safe("cd core && cargo test -q parse_command::tests"), - vec![ParsedCommand::Test { - cmd: "cargo test -q parse_command::tests".to_string(), - }], - ); - } - - #[test] - fn cargo_test_with_crate_4() { - assert_parsed( - &shlex_split_safe("cd core && cargo test --all-features parse_command -- --nocapture"), - vec![ParsedCommand::Test { - cmd: "cargo test --all-features parse_command -- --nocapture".to_string(), - }], - ); - } - - // Additional coverage for other common tools/frameworks - #[test] - fn recognizes_black_and_ruff() { - // black formats Python code - assert_parsed( - &shlex_split_safe("black src"), - vec![ParsedCommand::Format { - cmd: "black src".to_string(), - tool: Some("black".to_string()), - targets: Some(vec!["src".to_string()]), - }], - ); - - // ruff check is a linter; ensure we collect targets - assert_parsed( - &shlex_split_safe("ruff check ."), - vec![ParsedCommand::Lint { - cmd: "ruff check .".to_string(), - tool: Some("ruff".to_string()), - targets: Some(vec![".".to_string()]), - }], - ); - - // ruff format is a formatter - assert_parsed( - &shlex_split_safe("ruff format pkg/"), - vec![ParsedCommand::Format { - cmd: "ruff format pkg/".to_string(), - tool: Some("ruff".to_string()), - targets: Some(vec!["pkg/".to_string()]), - }], - ); - } - - #[test] - fn recognizes_pnpm_monorepo_test_and_npm_format_script() { - // pnpm -r test in a monorepo should still parse as a test action - assert_parsed( - &shlex_split_safe("pnpm -r test"), - vec![ParsedCommand::Test { - cmd: "pnpm -r test".to_string(), - }], - ); - - // npm run format should be recognized as a format action - assert_parsed( - &shlex_split_safe("npm run format -- -w ."), - vec![ParsedCommand::Format { - cmd: "npm run format -- -w .".to_string(), - tool: Some("npm-script:format".to_string()), - targets: None, - }], - ); - } - - #[test] - fn yarn_test_is_parsed_as_test() { - assert_parsed( - &shlex_split_safe("yarn test"), - vec![ParsedCommand::Test { - cmd: "yarn test".to_string(), - }], - ); - } - - #[test] - fn pytest_file_only_and_go_run_regex() { - // pytest invoked with a file path should be captured as a filter - assert_parsed( - &shlex_split_safe("pytest tests/test_example.py"), - vec![ParsedCommand::Test { - cmd: "pytest tests/test_example.py".to_string(), - }], - ); - - // go test with -run regex should capture the filter - assert_parsed( - &shlex_split_safe("go test ./... -run '^TestFoo$'"), - vec![ParsedCommand::Test { - cmd: "go test ./... -run '^TestFoo$'".to_string(), - }], - ); - } - #[test] fn grep_with_query_and_path() { assert_parsed( @@ -1090,30 +811,6 @@ mod tests { ); } - #[test] - fn eslint_with_config_path_and_target() { - assert_parsed( - &shlex_split_safe("eslint -c .eslintrc.json src"), - vec![ParsedCommand::Lint { - cmd: "eslint -c .eslintrc.json src".to_string(), - tool: Some("eslint".to_string()), - targets: Some(vec!["src".to_string()]), - }], - ); - } - - #[test] - fn npx_eslint_with_config_path_and_target() { - assert_parsed( - &shlex_split_safe("npx eslint -c .eslintrc src"), - vec![ParsedCommand::Lint { - cmd: "npx eslint -c .eslintrc src".to_string(), - tool: Some("eslint".to_string()), - targets: Some(vec!["src".to_string()]), - }], - ); - } - #[test] fn fd_file_finder_variants() { assert_parsed( @@ -1202,16 +899,13 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { return Some(commands[1..].to_vec()); } - // cd foo && [any Test command] => [any Test command] + // cd foo && [any command] => [any command] (keep non-cd when a cd is followed by something) if let Some(idx) = commands.iter().position(|pc| match pc { ParsedCommand::Unknown { cmd } => { shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("cd")) } _ => false, - }) && commands - .iter() - .skip(idx + 1) - .any(|pc| matches!(pc, ParsedCommand::Test { .. })) + }) && commands.len() > idx + 1 { let mut out = Vec::with_capacity(commands.len() - 1); out.extend_from_slice(&commands[..idx]); @@ -1220,10 +914,10 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { } // cmd || true => cmd - if let Some(idx) = commands.iter().position(|pc| match pc { - ParsedCommand::Noop { cmd } => cmd == "true", - _ => false, - }) { + if let Some(idx) = commands + .iter() + .position(|pc| matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")) + { let mut out = Vec::with_capacity(commands.len() - 1); out.extend_from_slice(&commands[..idx]); out.extend_from_slice(&commands[idx + 1..]); @@ -1377,75 +1071,6 @@ fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a out } -/// Common flags for ESLint that take a following value and should not be -/// considered positional targets. -const ESLINT_FLAGS_WITH_VALUES: &[&str] = &[ - "-c", - "--config", - "--parser", - "--parser-options", - "--rulesdir", - "--plugin", - "--max-warnings", - "--format", -]; - -fn collect_non_flag_targets(args: &[String]) -> Option> { - let mut targets = Vec::new(); - let mut skip_next = false; - for (i, a) in args.iter().enumerate() { - if a == "--" { - break; - } - if skip_next { - skip_next = false; - continue; - } - if a == "-p" - || a == "--package" - || a == "--features" - || a == "-C" - || a == "--config" - || a == "--config-path" - || a == "--out-dir" - || a == "-o" - || a == "--run" - || a == "--max-warnings" - || a == "--format" - { - if i + 1 < args.len() { - skip_next = true; - } - continue; - } - if a.starts_with('-') { - continue; - } - targets.push(a.clone()); - } - if targets.is_empty() { - None - } else { - Some(targets) - } -} - -fn collect_non_flag_targets_with_flags( - args: &[String], - flags_with_vals: &[&str], -) -> Option> { - let targets: Vec = skip_flag_values(args, flags_with_vals) - .into_iter() - .filter(|a| !a.starts_with('-')) - .cloned() - .collect(); - if targets.is_empty() { - None - } else { - Some(targets) - } -} - fn is_pathish(s: &str) -> bool { s == "." || s == ".." @@ -1514,47 +1139,6 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option, Option (query, path) } -fn classify_npm_like(tool: &str, tail: &[String], full_cmd: &[String]) -> Option { - let mut r = tail; - if tool == "pnpm" && r.first().map(|s| s.as_str()) == Some("-r") { - r = &r[1..]; - } - let mut script_name: Option = None; - if r.first().map(|s| s.as_str()) == Some("run") { - script_name = r.get(1).cloned(); - } else { - let is_test_cmd = (tool == "npm" && r.first().map(|s| s.as_str()) == Some("t")) - || ((tool == "npm" || tool == "pnpm" || tool == "yarn") - && r.first().map(|s| s.as_str()) == Some("test")); - if is_test_cmd { - script_name = Some("test".to_string()); - } - } - if let Some(name) = script_name { - let lname = name.to_lowercase(); - if lname == "test" || lname == "unit" || lname == "jest" || lname == "vitest" { - return Some(ParsedCommand::Test { - cmd: shlex_join(full_cmd), - }); - } - if lname == "lint" || lname == "eslint" { - return Some(ParsedCommand::Lint { - cmd: shlex_join(full_cmd), - tool: Some(format!("{tool}-script:{name}")), - targets: None, - }); - } - if lname == "format" || lname == "fmt" || lname == "prettier" { - return Some(ParsedCommand::Format { - cmd: shlex_join(full_cmd), - tool: Some(format!("{tool}-script:{name}")), - targets: None, - }); - } - } - None -} - fn parse_bash_lc_commands(original: &[String]) -> Option> { let [bash, flag, script] = original else { return None; @@ -1586,7 +1170,11 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { .map(|tokens| summarize_main_tokens(&tokens)) .collect(); if commands.len() > 1 { - commands.retain(|pc| !matches!(pc, ParsedCommand::Noop { .. })); + commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); + // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. + while let Some(next) = simplify_once(&commands) { + commands = next; + } } if commands.len() == 1 { // If we reduced to a single command, attribute the full original script @@ -1655,27 +1243,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { } } } - ParsedCommand::Format { - tool, targets, cmd, .. - } => ParsedCommand::Format { - cmd: cmd.clone(), - tool, - targets, - }, - ParsedCommand::Test { cmd, .. } => ParsedCommand::Test { cmd: cmd.clone() }, - ParsedCommand::Lint { - tool, targets, cmd, .. - } => ParsedCommand::Lint { - cmd: cmd.clone(), - tool, - targets, - }, - ParsedCommand::Unknown { .. } => ParsedCommand::Unknown { - cmd: script.clone(), - }, - ParsedCommand::Noop { .. } => ParsedCommand::Noop { - cmd: script.clone(), - }, + other => other, }) .collect(); } @@ -1728,124 +1296,6 @@ fn drop_small_formatting_commands(mut commands: Vec>) -> Vec ParsedCommand { match main_cmd.split_first() { - Some((head, tail)) if head == "true" && tail.is_empty() => ParsedCommand::Noop { - cmd: shlex_join(main_cmd), - }, - // (sed-specific logic handled below in dedicated arm returning Read) - Some((head, tail)) - if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("fmt") => - { - ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("cargo fmt".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - Some((head, tail)) - if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("clippy") => - { - ParsedCommand::Lint { - cmd: shlex_join(main_cmd), - tool: Some("cargo clippy".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - Some((head, tail)) - if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("test") => - { - ParsedCommand::Test { - cmd: shlex_join(main_cmd), - } - } - Some((head, tail)) if head == "rustfmt" => ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("rustfmt".to_string()), - targets: collect_non_flag_targets(tail), - }, - Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("fmt") => { - ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("go fmt".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("test") => { - ParsedCommand::Test { - cmd: shlex_join(main_cmd), - } - } - Some((head, _)) if head == "pytest" => ParsedCommand::Test { - cmd: shlex_join(main_cmd), - }, - Some((head, tail)) if head == "eslint" => { - // Treat configuration flags with values (e.g. `-c .eslintrc`) as non-targets. - let targets = collect_non_flag_targets_with_flags(tail, ESLINT_FLAGS_WITH_VALUES); - ParsedCommand::Lint { - cmd: shlex_join(main_cmd), - tool: Some("eslint".to_string()), - targets, - } - } - Some((head, tail)) if head == "prettier" => ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("prettier".to_string()), - targets: collect_non_flag_targets(tail), - }, - Some((head, tail)) if head == "black" => ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("black".to_string()), - targets: collect_non_flag_targets(tail), - }, - Some((head, tail)) - if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("check") => - { - ParsedCommand::Lint { - cmd: shlex_join(main_cmd), - tool: Some("ruff".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - Some((head, tail)) - if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("format") => - { - ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("ruff".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - Some((head, _)) if (head == "jest" || head == "vitest") => ParsedCommand::Test { - cmd: shlex_join(main_cmd), - }, - Some((head, tail)) - if head == "npx" && tail.first().map(|s| s.as_str()) == Some("eslint") => - { - let targets = collect_non_flag_targets_with_flags(&tail[1..], ESLINT_FLAGS_WITH_VALUES); - ParsedCommand::Lint { - cmd: shlex_join(main_cmd), - tool: Some("eslint".to_string()), - targets, - } - } - Some((head, tail)) - if head == "npx" && tail.first().map(|s| s.as_str()) == Some("prettier") => - { - ParsedCommand::Format { - cmd: shlex_join(main_cmd), - tool: Some("prettier".to_string()), - targets: collect_non_flag_targets(&tail[1..]), - } - } - // NPM-like scripts including yarn - Some((tool, tail)) if (tool == "pnpm" || tool == "npm" || tool == "yarn") => { - if let Some(cmd) = classify_npm_like(tool, tail, main_cmd) { - cmd - } else { - ParsedCommand::Unknown { - cmd: shlex_join(main_cmd), - } - } - } Some((head, tail)) if head == "ls" => { // Avoid treating option values as paths (e.g., ls -I "*.test.js"). let candidates = skip_flag_values( diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs new file mode 100644 index 0000000000..375140c47a --- /dev/null +++ b/codex-rs/core/src/rollout/list.rs @@ -0,0 +1,295 @@ +use std::cmp::Reverse; +use std::io::{self}; +use std::path::Path; +use std::path::PathBuf; + +use time::OffsetDateTime; +use time::PrimitiveDateTime; +use time::format_description::FormatItem; +use time::macros::format_description; +use uuid::Uuid; + +use super::SESSIONS_SUBDIR; + +/// Returned page of conversation summaries. +#[derive(Debug, Default, PartialEq)] +pub struct ConversationsPage { + /// Conversation summaries ordered newest first. + pub items: Vec, + /// Opaque pagination token to resume after the last item, or `None` if end. + pub next_cursor: Option, + /// Total number of files touched while scanning this request. + pub num_scanned_files: usize, + /// True if a hard scan cap was hit; consider resuming with `next_cursor`. + pub reached_scan_cap: bool, +} + +/// Summary information for a conversation rollout file. +#[derive(Debug, PartialEq)] +pub struct ConversationItem { + /// Absolute path to the rollout file. + pub path: PathBuf, + /// First up to 5 JSONL records parsed as JSON (includes meta line). + pub head: Vec, +} + +/// Hard cap to bound worst‑case work per request. +const MAX_SCAN_FILES: usize = 50_000; + +/// Pagination cursor identifying a file by timestamp and UUID. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cursor { + ts: OffsetDateTime, + id: Uuid, +} + +impl Cursor { + fn new(ts: OffsetDateTime, id: Uuid) -> Self { + Self { ts, id } + } +} + +impl serde::Serialize for Cursor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let ts_str = self + .ts + .format(&format_description!( + "[year]-[month]-[day]T[hour]-[minute]-[second]" + )) + .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; + serializer.serialize_str(&format!("{ts_str}|{}", self.id)) + } +} + +impl<'de> serde::Deserialize<'de> for Cursor { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_cursor(&s).ok_or_else(|| serde::de::Error::custom("invalid cursor")) + } +} + +/// Retrieve recorded conversation file paths with token pagination. The returned `next_cursor` +/// can be supplied on the next call to resume after the last returned item, resilient to +/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc. +pub(crate) async fn get_conversations( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, +) -> io::Result { + let mut root = codex_home.to_path_buf(); + root.push(SESSIONS_SUBDIR); + + if !root.exists() { + return Ok(ConversationsPage { + items: Vec::new(), + next_cursor: None, + num_scanned_files: 0, + reached_scan_cap: false, + }); + } + + let anchor = cursor.cloned(); + + let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?; + Ok(result) +} + +/// Load the full contents of a single conversation session file at `path`. +/// Returns the entire file contents as a String. +#[allow(dead_code)] +pub(crate) async fn get_conversation(path: &Path) -> io::Result { + tokio::fs::read_to_string(path).await +} + +/// Load conversation file paths from disk using directory traversal. +/// +/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-.jsonl` +/// Returned newest (latest) first. +async fn traverse_directories_for_paths( + root: PathBuf, + page_size: usize, + anchor: Option, +) -> io::Result { + let mut items: Vec = Vec::with_capacity(page_size); + let mut scanned_files = 0usize; + let mut anchor_passed = anchor.is_none(); + let (anchor_ts, anchor_id) = match anchor { + Some(c) => (c.ts, c.id), + None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()), + }; + + let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; + + 'outer: for (_year, year_path) in year_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break; + } + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str) + .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) + }) + .await?; + // Stable ordering within the same second: (timestamp desc, uuid desc) + day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); + for (ts, sid, _name_str, path) in day_files.into_iter() { + scanned_files += 1; + if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + break 'outer; + } + if !anchor_passed { + if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { + anchor_passed = true; + } else { + continue; + } + } + if items.len() == page_size { + break 'outer; + } + let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default(); + items.push(ConversationItem { path, head }); + } + } + } + } + + let next = build_next_cursor(&items); + Ok(ConversationsPage { + items, + next_cursor: next, + num_scanned_files: scanned_files, + reached_scan_cap: scanned_files >= MAX_SCAN_FILES, + }) +} + +/// Pagination cursor token format: "|" where `file_ts` matches the +/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames. +/// The cursor orders files by timestamp desc, then UUID desc. +fn parse_cursor(token: &str) -> Option { + let (file_ts, uuid_str) = token.split_once('|')?; + + let Ok(uuid) = Uuid::parse_str(uuid_str) else { + return None; + }; + + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let ts = PrimitiveDateTime::parse(file_ts, format).ok()?.assume_utc(); + + Some(Cursor::new(ts, uuid)) +} + +fn build_next_cursor(items: &[ConversationItem]) -> Option { + let last = items.last()?; + let file_name = last.path.file_name()?.to_string_lossy(); + let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + Some(Cursor::new(ts, id)) +} + +/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`, +/// and returns them sorted descending by the parsed key. +async fn collect_dirs_desc(parent: &Path, parse: F) -> io::Result> +where + T: Ord + Copy, + F: Fn(&str) -> Option, +{ + let mut dir = tokio::fs::read_dir(parent).await?; + let mut vec: Vec<(T, PathBuf)> = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + if entry + .file_type() + .await + .map(|ft| ft.is_dir()) + .unwrap_or(false) + && let Some(s) = entry.file_name().to_str() + && let Some(v) = parse(s) + { + vec.push((v, entry.path())); + } + } + vec.sort_by_key(|(v, _)| Reverse(*v)); + Ok(vec) +} + +/// Collects files in a directory and parses them with `parse`. +async fn collect_files(parent: &Path, parse: F) -> io::Result> +where + F: Fn(&str, &Path) -> Option, +{ + let mut dir = tokio::fs::read_dir(parent).await?; + let mut collected: Vec = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + if entry + .file_type() + .await + .map(|ft| ft.is_file()) + .unwrap_or(false) + && let Some(s) = entry.file_name().to_str() + && let Some(v) = parse(s, &entry.path()) + { + collected.push(v); + } + } + Ok(collected) +} + +fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { + // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl + let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; + + // Scan from the right for a '-' such that the suffix parses as a UUID. + let (sep_idx, uuid) = core + .match_indices('-') + .rev() + .find_map(|(i, _)| Uuid::parse_str(&core[i + 1..]).ok().map(|u| (i, u)))?; + + let ts_str = &core[..sep_idx]; + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let ts = PrimitiveDateTime::parse(ts_str, format).ok()?.assume_utc(); + Some((ts, uuid)) +} + +async fn read_first_jsonl_records( + path: &Path, + max_records: usize, +) -> io::Result> { + use tokio::io::AsyncBufReadExt; + + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + let mut head: Vec = Vec::new(); + while head.len() < max_records { + let line_opt = lines.next_line().await?; + let Some(line) = line_opt else { break }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(trimmed) { + head.push(v); + } + } + Ok(head) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs new file mode 100644 index 0000000000..c6b4fc58a3 --- /dev/null +++ b/codex-rs/core/src/rollout/mod.rs @@ -0,0 +1,13 @@ +//! Rollout module: persistence and discovery of session rollout files. + +pub(crate) const SESSIONS_SUBDIR: &str = "sessions"; + +pub mod list; +pub(crate) mod policy; +pub mod recorder; + +pub use recorder::RolloutRecorder; +pub use recorder::SessionStateSnapshot; + +#[cfg(test)] +pub mod tests; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs new file mode 100644 index 0000000000..3eb5290321 --- /dev/null +++ b/codex-rs/core/src/rollout/policy.rs @@ -0,0 +1,16 @@ +use codex_protocol::models::ResponseItem; + +/// Whether a `ResponseItem` should be persisted in rollout files. +#[inline] +pub(crate) fn is_persisted_response_item(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } => true, + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false, + } +} diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout/recorder.rs similarity index 75% rename from codex-rs/core/src/rollout.rs rename to codex-rs/core/src/rollout/recorder.rs index 3c7162e883..68bcb439d7 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -19,13 +19,17 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +use super::SESSIONS_SUBDIR; +use super::list::ConversationsPage; +use super::list::Cursor; +use super::list::get_conversations; +use super::policy::is_persisted_response_item; use crate::config::Config; +use crate::conversation_manager::InitialHistory; use crate::git_info::GitInfo; use crate::git_info::collect_git_info; use codex_protocol::models::ResponseItem; -const SESSIONS_SUBDIR: &str = "sessions"; - #[derive(Serialize, Deserialize, Clone, Default)] pub struct SessionMeta { pub id: Uuid, @@ -64,7 +68,7 @@ pub struct SavedSession { /// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl /// ``` #[derive(Clone)] -pub(crate) struct RolloutRecorder { +pub struct RolloutRecorder { tx: Sender, } @@ -75,6 +79,16 @@ enum RolloutCmd { } impl RolloutRecorder { + #[allow(dead_code)] + /// List conversations (rollout files) under the provided Codex home directory. + pub async fn list_conversations( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + ) -> std::io::Result { + get_conversations(codex_home, page_size, cursor).await + } + /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// cannot be created or the rollout file cannot be opened we return the /// error so the caller can decide whether to disable persistence. @@ -124,21 +138,11 @@ impl RolloutRecorder { pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> { let mut filtered = Vec::new(); for item in items { - match item { - // Note that function calls may look a bit strange if they are - // "fully qualified MCP tool calls," so we could consider - // reformatting them in that case. - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => filtered.push(item.clone()), - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => { - // These should never be serialized. - continue; - } + // Note that function calls may look a bit strange if they are + // "fully qualified MCP tool calls," so we could consider + // reformatting them in that case. + if is_persisted_response_item(item) { + filtered.push(item.clone()); } } if filtered.is_empty() { @@ -157,20 +161,14 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to queue rollout state: {e}"))) } - pub async fn resume( - path: &Path, - cwd: std::path::PathBuf, - ) -> std::io::Result<(Self, SavedSession)> { + pub async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; let mut lines = text.lines(); - let meta_line = lines + let _ = lines .next() .ok_or_else(|| IoError::other("empty session file"))?; - let session: SessionMeta = serde_json::from_str(meta_line) - .map_err(|e| IoError::other(format!("failed to parse session meta: {e}")))?; let mut items = Vec::new(); - let mut state = SessionStateSnapshot::default(); for line in lines { if line.trim().is_empty() { @@ -185,49 +183,26 @@ impl RolloutRecorder { .map(|s| s == "state") .unwrap_or(false) { - if let Ok(s) = serde_json::from_value::(v.clone()) { - state = s - } continue; } match serde_json::from_value::(v.clone()) { - Ok(item) => match item { - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => items.push(item), - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} - }, + Ok(item) => { + if is_persisted_response_item(&item) { + items.push(item); + } + } Err(e) => { warn!("failed to parse item: {v:?}, error: {e}"); } } } - let saved = SavedSession { - session: session.clone(), - items: items.clone(), - state: state.clone(), - session_id: session.id, - }; - - let file = std::fs::OpenOptions::new() - .append(true) - .read(true) - .open(path)?; - - let (tx, rx) = mpsc::channel::(256); - tokio::task::spawn(rollout_writer( - tokio::fs::File::from_std(file), - rx, - None, - cwd, - )); info!("Resumed rollout successfully from {path:?}"); - Ok((Self { tx }, saved)) + if items.is_empty() { + Ok(InitialHistory::New) + } else { + Ok(InitialHistory::Resumed(items)) + } } pub async fn shutdown(&self) -> std::io::Result<()> { @@ -316,17 +291,8 @@ async fn rollout_writer( match cmd { RolloutCmd::AddItems(items) => { for item in items { - match item { - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => { - writer.write_line(&item).await?; - } - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} + if is_persisted_response_item(&item) { + writer.write_line(&item).await?; } } } diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs new file mode 100644 index 0000000000..52c121e772 --- /dev/null +++ b/codex-rs/core/src/rollout/tests.rs @@ -0,0 +1,384 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::path::Path; + +use tempfile::TempDir; +use time::OffsetDateTime; +use time::PrimitiveDateTime; +use time::format_description::FormatItem; +use time::macros::format_description; +use uuid::Uuid; + +use crate::rollout::list::ConversationItem; +use crate::rollout::list::ConversationsPage; +use crate::rollout::list::Cursor; +use crate::rollout::list::get_conversation; +use crate::rollout::list::get_conversations; + +fn write_session_file( + root: &Path, + ts_str: &str, + uuid: Uuid, + num_records: usize, +) -> std::io::Result<(OffsetDateTime, Uuid)> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + let meta = serde_json::json!({ + "timestamp": ts_str, + "id": uuid.to_string() + }); + writeln!(file, "{meta}")?; + + for i in 0..num_records { + let rec = serde_json::json!({ + "record_type": "response", + "index": i + }); + writeln!(file, "{rec}")?; + } + Ok((dt, uuid)) +} + +#[tokio::test] +async fn test_list_conversations_latest_first() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + // Fixed UUIDs for deterministic expectations + let u1 = Uuid::from_u128(1); + let u2 = Uuid::from_u128(2); + let u3 = Uuid::from_u128(3); + + // Create three sessions across three days + write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap(); + write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap(); + write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap(); + + let page = get_conversations(home, 10, None).await.unwrap(); + + // Build expected objects + let p1 = home + .join("sessions") + .join("2025") + .join("01") + .join("03") + .join(format!("rollout-2025-01-03T12-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("01") + .join("02") + .join(format!("rollout-2025-01-02T12-00-00-{u2}.jsonl")); + let p3 = home + .join("sessions") + .join("2025") + .join("01") + .join("01") + .join(format!("rollout-2025-01-01T12-00-00-{u1}.jsonl")); + + let head_3 = vec![ + serde_json::json!({"timestamp": "2025-01-03T12-00-00", "id": u3.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + let head_2 = vec![ + serde_json::json!({"timestamp": "2025-01-02T12-00-00", "id": u2.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + let head_1 = vec![ + serde_json::json!({"timestamp": "2025-01-01T12-00-00", "id": u1.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + + let expected_cursor: Cursor = + serde_json::from_str(&format!("\"2025-01-01T12-00-00|{u1}\"")).unwrap(); + + let expected = ConversationsPage { + items: vec![ + ConversationItem { + path: p1, + head: head_3, + }, + ConversationItem { + path: p2, + head: head_2, + }, + ConversationItem { + path: p3, + head: head_1, + }, + ], + next_cursor: Some(expected_cursor), + num_scanned_files: 3, + reached_scan_cap: false, + }; + + assert_eq!(page, expected); +} + +#[tokio::test] +async fn test_pagination_cursor() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + // Fixed UUIDs for deterministic expectations + let u1 = Uuid::from_u128(11); + let u2 = Uuid::from_u128(22); + let u3 = Uuid::from_u128(33); + let u4 = Uuid::from_u128(44); + let u5 = Uuid::from_u128(55); + + // Oldest to newest + write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap(); + write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap(); + write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap(); + write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap(); + write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap(); + + let page1 = get_conversations(home, 2, None).await.unwrap(); + let p5 = home + .join("sessions") + .join("2025") + .join("03") + .join("05") + .join(format!("rollout-2025-03-05T09-00-00-{u5}.jsonl")); + let p4 = home + .join("sessions") + .join("2025") + .join("03") + .join("04") + .join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl")); + let head_5 = vec![ + serde_json::json!({"timestamp": "2025-03-05T09-00-00", "id": u5.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let head_4 = vec![ + serde_json::json!({"timestamp": "2025-03-04T09-00-00", "id": u4.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor1: Cursor = + serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); + let expected_page1 = ConversationsPage { + items: vec![ + ConversationItem { + path: p5, + head: head_5, + }, + ConversationItem { + path: p4, + head: head_4, + }, + ], + next_cursor: Some(expected_cursor1.clone()), + num_scanned_files: 3, // scanned 05, 04, and peeked at 03 before breaking + reached_scan_cap: false, + }; + assert_eq!(page1, expected_page1); + + let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) + .await + .unwrap(); + let p3 = home + .join("sessions") + .join("2025") + .join("03") + .join("03") + .join(format!("rollout-2025-03-03T09-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("03") + .join("02") + .join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl")); + let head_3 = vec![ + serde_json::json!({"timestamp": "2025-03-03T09-00-00", "id": u3.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let head_2 = vec![ + serde_json::json!({"timestamp": "2025-03-02T09-00-00", "id": u2.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor2: Cursor = + serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap(); + let expected_page2 = ConversationsPage { + items: vec![ + ConversationItem { + path: p3, + head: head_3, + }, + ConversationItem { + path: p2, + head: head_2, + }, + ], + next_cursor: Some(expected_cursor2.clone()), + num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02, and peeked at 01 + reached_scan_cap: false, + }; + assert_eq!(page2, expected_page2); + + let page3 = get_conversations(home, 2, page2.next_cursor.as_ref()) + .await + .unwrap(); + let p1 = home + .join("sessions") + .join("2025") + .join("03") + .join("01") + .join(format!("rollout-2025-03-01T09-00-00-{u1}.jsonl")); + let head_1 = vec![ + serde_json::json!({"timestamp": "2025-03-01T09-00-00", "id": u1.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor3: Cursor = + serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap(); + let expected_page3 = ConversationsPage { + items: vec![ConversationItem { + path: p1, + head: head_1, + }], + next_cursor: Some(expected_cursor3.clone()), + num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01 + reached_scan_cap: false, + }; + assert_eq!(page3, expected_page3); +} + +#[tokio::test] +async fn test_get_conversation_contents() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let uuid = Uuid::new_v4(); + let ts = "2025-04-01T10-30-00"; + write_session_file(home, ts, uuid, 2).unwrap(); + + let page = get_conversations(home, 1, None).await.unwrap(); + let path = &page.items[0].path; + + let content = get_conversation(path).await.unwrap(); + + // Page equality (single item) + let expected_path = home + .join("sessions") + .join("2025") + .join("04") + .join("01") + .join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl")); + let expected_head = vec![ + serde_json::json!({"timestamp": ts, "id": uuid.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + ]; + let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap(); + let expected_page = ConversationsPage { + items: vec![ConversationItem { + path: expected_path.clone(), + head: expected_head, + }], + next_cursor: Some(expected_cursor), + num_scanned_files: 1, + reached_scan_cap: false, + }; + assert_eq!(page, expected_page); + + // Entire file contents equality + let meta = serde_json::json!({"timestamp": ts, "id": uuid.to_string()}); + let rec0 = serde_json::json!({"record_type": "response", "index": 0}); + let rec1 = serde_json::json!({"record_type": "response", "index": 1}); + let expected_content = format!("{meta}\n{rec0}\n{rec1}\n"); + assert_eq!(content, expected_content); +} + +#[tokio::test] +async fn test_stable_ordering_same_second_pagination() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-07-01T00-00-00"; + let u1 = Uuid::from_u128(1); + let u2 = Uuid::from_u128(2); + let u3 = Uuid::from_u128(3); + + write_session_file(home, ts, u1, 0).unwrap(); + write_session_file(home, ts, u2, 0).unwrap(); + write_session_file(home, ts, u3, 0).unwrap(); + + let page1 = get_conversations(home, 2, None).await.unwrap(); + + let p3 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl")); + let head = |u: Uuid| -> Vec { + vec![serde_json::json!({"timestamp": ts, "id": u.to_string()})] + }; + let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap(); + let expected_page1 = ConversationsPage { + items: vec![ + ConversationItem { + path: p3, + head: head(u3), + }, + ConversationItem { + path: p2, + head: head(u2), + }, + ], + next_cursor: Some(expected_cursor1.clone()), + num_scanned_files: 3, // scanned u3, u2, peeked u1 + reached_scan_cap: false, + }; + assert_eq!(page1, expected_page1); + + let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) + .await + .unwrap(); + let p1 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl")); + let expected_cursor2: Cursor = serde_json::from_str(&format!("\"{ts}|{u1}\"")).unwrap(); + let expected_page2 = ConversationsPage { + items: vec![ConversationItem { + path: p1, + head: head(u1), + }], + next_cursor: Some(expected_cursor2.clone()), + num_scanned_files: 3, // scanned u3, u2 (anchor), u1 + reached_scan_cap: false, + }; + assert_eq!(page2, expected_page2); +} diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 37cb8f4e4c..790267f55a 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -53,6 +53,13 @@ pub fn assess_patch_safety( // paths outside the project. match get_platform_sandbox() { Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type }, + None if sandbox_policy == &SandboxPolicy::DangerFullAccess => { + // If the user has explicitly requested DangerFullAccess, then + // we can auto-approve even without a sandbox. + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + } + } None => SafetyCheck::AskUser, } } else if policy == AskForApproval::Never { @@ -222,7 +229,7 @@ fn is_write_patch_constrained_to_writable_paths( for (path, change) in action.changes() { match change { - ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete => { + ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete { .. } => { if !is_path_writable(path) { return false; } diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/core/src/token_data.rs similarity index 82% rename from codex-rs/login/src/token_data.rs rename to codex-rs/core/src/token_data.rs index f6d04f16a6..626b030541 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use serde::Serialize; use thiserror::Error; -use crate::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] pub struct TokenData { @@ -58,7 +58,7 @@ pub struct IdTokenInfo { pub email: Option, /// The ChatGPT subscription plan type /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). - /// (Note: ae has not verified that those are the exact values.) + /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, pub raw_jwt: String, } @@ -137,7 +137,7 @@ pub enum IdTokenInfoError { Json(#[from] serde_json::Error), } -pub(crate) fn parse_id_token(id_token: &str) -> Result { +pub fn parse_id_token(id_token: &str) -> Result { // JWT format: header.payload.signature let mut parts = id_token.split('.'); let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { @@ -204,9 +204,33 @@ mod tests { let info = parse_id_token(&fake_jwt).expect("should parse"); assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!( - info.chatgpt_plan_type, - Some(PlanType::Known(KnownPlan::Pro)) - ); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); + } + + #[test] + fn id_token_info_handles_missing_fields() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ "sub": "123" }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.get_chatgpt_plan_type().is_none()); } } diff --git a/codex-rs/core/src/tool_apply_patch.lark b/codex-rs/core/src/tool_apply_patch.lark new file mode 100644 index 0000000000..5aa41b0af7 --- /dev/null +++ b/codex-rs/core/src/tool_apply_patch.lark @@ -0,0 +1,19 @@ +start: begin_patch hunk+ end_patch +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? + +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change_move? change? + +filename: /(.+)/ +add_line: "+" /(.*)/ LF -> line + +change_move: "*** Move to: " filename LF +change: (change_context | change_line)+ eof_line? +change_context: ("@@" | "@@ " /(.+)/) LF +change_line: ("+" | "-" | " ") /(.*)/ LF +eof_line: "*** End of File" LF + +%import common.LF diff --git a/codex-rs/core/src/tool_apply_patch.rs b/codex-rs/core/src/tool_apply_patch.rs index 3868cf0e70..d4039fd192 100644 --- a/codex-rs/core/src/tool_apply_patch.rs +++ b/codex-rs/core/src/tool_apply_patch.rs @@ -8,6 +8,8 @@ use crate::openai_tools::JsonSchema; use crate::openai_tools::OpenAiTool; use crate::openai_tools::ResponsesApiTool; +const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark"); + #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { pub(crate) input: String, @@ -29,27 +31,7 @@ pub(crate) fn create_apply_patch_freeform_tool() -> OpenAiTool { format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), - definition: r#"start: begin_patch hunk+ end_patch -begin_patch: "*** Begin Patch" LF -end_patch: "*** End Patch" LF? - -hunk: add_hunk | delete_hunk | update_hunk -add_hunk: "*** Add File: " filename LF add_line+ -delete_hunk: "*** Delete File: " filename LF -update_hunk: "*** Update File: " filename LF change_move? change? - -filename: /(.+)/ -add_line: "+" /(.+)/ LF -> line - -change_move: "*** Move to: " filename LF -change: (change_context | change_line)+ eof_line? -change_context: ("@@" | "@@ " /(.+)/) LF -change_line: ("+" | "-" | " ") /(.+)/ LF -eof_line: "*** End of File" LF - -%import common.LF -"# - .to_string(), + definition: APPLY_PATCH_LARK_GRAMMAR.to_string(), }, }) } diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 5e73fa0650..2157ed1784 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -578,7 +578,12 @@ index {ZERO_OID}..{right_oid} fs::write(&file, "x\n").unwrap(); let mut acc = TurnDiffTracker::new(); - let del_changes = HashMap::from([(file.clone(), FileChange::Delete)]); + let del_changes = HashMap::from([( + file.clone(), + FileChange::Delete { + content: "x\n".to_string(), + }, + )]); acc.on_patch_begin(&del_changes); // Simulate apply: delete the file from disk. @@ -741,7 +746,12 @@ index {left_oid}..{right_oid} assert_eq!(first, expected_first); // Next: introduce a brand-new path b.txt into baseline snapshots via a delete change. - let del_b = HashMap::from([(b.clone(), FileChange::Delete)]); + let del_b = HashMap::from([( + b.clone(), + FileChange::Delete { + content: "z\n".to_string(), + }, + )]); acc.on_patch_begin(&del_b); // Simulate apply: delete b.txt. let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular); diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs deleted file mode 100644 index a63170cebd..0000000000 --- a/codex-rs/core/src/user_agent.rs +++ /dev/null @@ -1,37 +0,0 @@ -const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; - -pub fn get_codex_user_agent(originator: Option<&str>) -> String { - let build_version = env!("CARGO_PKG_VERSION"); - let os_info = os_info::get(); - format!( - "{}/{build_version} ({} {}; {}) {}", - originator.unwrap_or(DEFAULT_ORIGINATOR), - os_info.os_type(), - os_info.version(), - os_info.architecture().unwrap_or("unknown"), - crate::terminal::user_agent() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_codex_user_agent() { - let user_agent = get_codex_user_agent(None); - assert!(user_agent.starts_with("codex_cli_rs/")); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_macos() { - use regex_lite::Regex; - let user_agent = get_codex_user_agent(None); - let re = Regex::new( - r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", - ) - .unwrap(); - assert!(re.is_match(&user_agent)); - } -} diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index e1248a4867..8ef258d9c6 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::time::Duration; use rand::Rng; @@ -12,33 +11,3 @@ pub(crate) fn backoff(attempt: u64) -> Duration { let jitter = rand::rng().random_range(0.9..1.1); Duration::from_millis((base as f64 * jitter) as u64) } - -/// Return `true` if the project folder specified by the `Config` is inside a -/// Git repository. -/// -/// The check walks up the directory hierarchy looking for a `.git` file or -/// directory (note `.git` can be a file that contains a `gitdir` entry). This -/// approach does **not** require the `git` binary or the `git2` crate and is -/// therefore fairly lightweight. -/// -/// Note that this does **not** detect *work‑trees* created with -/// `git worktree add` where the checkout lives outside the main repository -/// directory. If you need Codex to work from such a checkout simply pass the -/// `--allow-no-git-exec` CLI flag that disables the repo requirement. -pub fn is_inside_git_repo(base_dir: &Path) -> bool { - let mut dir = base_dir.to_path_buf(); - - loop { - if dir.join(".git").exists() { - return true; - } - - // Pop one component (go up one directory). `pop` returns false when - // we have reached the filesystem root. - if !dir.pop() { - break; - } - } - - false -} diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs new file mode 100644 index 0000000000..6b21894d75 --- /dev/null +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -0,0 +1,345 @@ +use std::sync::Arc; + +use codex_core::ContentItem; +use codex_core::LocalShellAction; +use codex_core::LocalShellExecAction; +use codex_core::LocalShellStatus; +use codex_core::ModelClient; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ReasoningItemContent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use core_test_support::load_default_config_for_test; +use futures::StreamExt; +use serde_json::Value; +use tempfile::TempDir; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn network_disabled() -> bool { + std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() +} + +async fn run_request(input: Vec) -> Value { + let server = MockServer::start().await; + + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw( + "data: {\"choices\":[{\"delta\":{}}]}\n\ndata: [DONE]\n\n", + "text/event-stream", + ); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(template) + .expect(1) + .mount(&server) + .await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + }; + + let codex_home = match TempDir::new() { + Ok(dir) => dir, + Err(e) => panic!("failed to create TempDir: {e}"), + }; + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.show_raw_agent_reasoning = true; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let client = ModelClient::new( + Arc::clone(&config), + None, + provider, + effort, + summary, + Uuid::new_v4(), + ); + + let mut prompt = Prompt::default(); + prompt.input = input; + + let mut stream = match client.stream(&prompt).await { + Ok(s) => s, + Err(e) => panic!("stream chat failed: {e}"), + }; + while let Some(event) = stream.next().await { + if let Err(e) = event { + panic!("stream event error: {e}"); + } + } + + let requests = match server.received_requests().await { + Some(reqs) => reqs, + None => panic!("request not made"), + }; + match requests[0].body_json() { + Ok(v) => v, + Err(e) => panic!("invalid json body: {e}"), + } +} + +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + } +} + +fn assistant_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } +} + +fn reasoning_item(text: &str) -> ResponseItem { + ResponseItem::Reasoning { + id: String::new(), + summary: Vec::new(), + content: Some(vec![ReasoningItemContent::ReasoningText { + text: text.to_string(), + }]), + encrypted_content: None, + } +} + +fn function_call() -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: "f".to_string(), + arguments: "{}".to_string(), + call_id: "c1".to_string(), + } +} + +fn local_shell_call() -> ResponseItem { + ResponseItem::LocalShellCall { + id: Some("id1".to_string()), + call_id: None, + status: LocalShellStatus::InProgress, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string()], + timeout_ms: Some(1_000), + working_directory: None, + env: None, + user: None, + }), + } +} + +fn messages_from(body: &Value) -> Vec { + match body["messages"].as_array() { + Some(arr) => arr.clone(), + None => panic!("messages array missing"), + } +} + +fn first_assistant(messages: &[Value]) -> &Value { + match messages.iter().find(|msg| msg["role"] == "assistant") { + Some(v) => v, + None => panic!("assistant message not present"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn omits_reasoning_when_none_present() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![user_message("u1"), assistant_message("a1")]).await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["content"], Value::String("a1".into())); + assert!(assistant.get("reasoning").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_previous_assistant() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + reasoning_item("rA"), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["content"], Value::String("a1".into())); + assert_eq!(assistant["reasoning"], Value::String("rA".into())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_function_call_anchor() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + reasoning_item("rFunc"), + function_call(), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["reasoning"], Value::String("rFunc".into())); + let tool_calls = match assistant["tool_calls"].as_array() { + Some(arr) => arr, + None => panic!("tool call list missing"), + }; + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0]["type"], Value::String("function".into())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_local_shell_call() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + reasoning_item("rShell"), + local_shell_call(), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["reasoning"], Value::String("rShell".into())); + assert_eq!( + assistant["tool_calls"][0]["type"], + Value::String("local_shell_call".into()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drops_reasoning_when_last_role_is_user() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + assistant_message("aPrev"), + reasoning_item("rHist"), + user_message("uNew"), + ]) + .await; + let messages = messages_from(&body); + assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ignores_reasoning_before_last_user() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + user_message("u2"), + reasoning_item("rAfterU1"), + ]) + .await; + let messages = messages_from(&body); + assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skips_empty_reasoning_segments() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + reasoning_item(""), + reasoning_item(" "), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + assert!(assistant.get("reasoning").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn suppresses_duplicate_assistant_messages() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![assistant_message("dup"), assistant_message("dup")]).await; + let messages = messages_from(&body); + let assistant_messages: Vec<_> = messages + .iter() + .filter(|msg| msg["role"] == "assistant") + .collect(); + assert_eq!(assistant_messages.len(), 1); + assert_eq!( + assistant_messages[0]["content"], + Value::String("dup".into()) + ); +} diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs new file mode 100644 index 0000000000..1df658dad6 --- /dev/null +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -0,0 +1,320 @@ +use std::sync::Arc; + +use codex_core::ContentItem; +use codex_core::ModelClient; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ResponseEvent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use core_test_support::load_default_config_for_test; +use futures::StreamExt; +use tempfile::TempDir; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn network_disabled() -> bool { + std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() +} + +async fn run_stream(sse_body: &str) -> Vec { + let server = MockServer::start().await; + + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_body.to_string(), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(template) + .expect(1) + .mount(&server) + .await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + }; + + let codex_home = match TempDir::new() { + Ok(dir) => dir, + Err(e) => panic!("failed to create TempDir: {e}"), + }; + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.show_raw_agent_reasoning = true; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let client = ModelClient::new( + Arc::clone(&config), + None, + provider, + effort, + summary, + Uuid::new_v4(), + ); + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hello".to_string(), + }], + }]; + + let mut stream = match client.stream(&prompt).await { + Ok(s) => s, + Err(e) => panic!("stream chat failed: {e}"), + }; + let mut events = Vec::new(); + while let Some(event) = stream.next().await { + match event { + Ok(ev) => events.push(ev), + Err(e) => panic!("stream event error: {e}"), + } + } + events +} + +fn assert_message(item: &ResponseItem, expected: &str) { + if let ResponseItem::Message { content, .. } = item { + let text = content.iter().find_map(|part| match part { + ContentItem::OutputText { text } | ContentItem::InputText { text } => Some(text), + _ => None, + }); + let Some(text) = text else { + panic!("message missing text: {item:?}"); + }; + assert_eq!(text, expected); + } else { + panic!("expected message item, got: {item:?}"); + } +} + +fn assert_reasoning(item: &ResponseItem, expected: &str) { + if let ResponseItem::Reasoning { + content: Some(parts), + .. + } = item + { + let mut combined = String::new(); + for part in parts { + match part { + codex_core::ReasoningItemContent::ReasoningText { text } + | codex_core::ReasoningItemContent::Text { text } => combined.push_str(text), + } + } + assert_eq!(combined, expected); + } else { + panic!("expected reasoning item, got: {item:?}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_text_without_reasoning() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{}}]}\n\n", + "data: [DONE]\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 3, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"), + other => panic!("expected terminal message, got {other:?}"), + } + + assert!(matches!(events[2], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_string_delta() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":\"think1\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 5, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "think1"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[3] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"), + other => panic!("expected message item, got {other:?}"), + } + + assert!(matches!(events[4], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_object_delta() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"text\":\"partA\"}}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"content\":\"partB\"}}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 6, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partA"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partB"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[3] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[4] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"), + other => panic!("expected message item, got {other:?}"), + } + + assert!(matches!(events[5], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_final_message() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = "data: {\"choices\":[{\"message\":{\"reasoning\":\"final-cot\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let events = run_stream(sse).await; + assert_eq!(events.len(), 3, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "final-cot"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"), + other => panic!("expected reasoning item, got {other:?}"), + } + + assert!(matches!(events[2], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_before_tool_call() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":\"pre-tool\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"run\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 4, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "pre-tool"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { + name, + arguments, + call_id, + .. + }) => { + assert_eq!(name, "run"); + assert_eq!(arguments, "{}"); + assert_eq!(call_id, "call_1"); + } + other => panic!("expected function call, got {other:?}"), + } + + assert!(matches!(events[3], ResponseEvent::Completed { .. })); +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index dd53a8d302..695aa9f1ba 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -388,7 +388,7 @@ async fn integration_creates_and_checks_session_file() { "No message found in session file containing the marker" ); - // Second run: resume and append. + // Second run: resume should create a NEW session file that contains both old and new history. let orig_len = content.lines().count(); let marker2 = format!("integration-resume-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); @@ -419,31 +419,58 @@ async fn integration_creates_and_checks_session_file() { let output2 = cmd2.output().unwrap(); assert!(output2.status.success(), "resume codex-cli run failed"); - // The rollout writer runs on a background async task; give it a moment to flush. - let mut new_len = orig_len; - let deadline = Instant::now() + Duration::from_secs(5); - let mut content2 = String::new(); - while Instant::now() < deadline { - if let Ok(c) = std::fs::read_to_string(&path) { - let count = c.lines().count(); - if count > orig_len { - content2 = c; - new_len = count; + // Find the new session file containing the resumed marker. + let deadline = Instant::now() + Duration::from_secs(10); + let mut resumed_path: Option = None; + while Instant::now() < deadline && resumed_path.is_none() { + for entry in WalkDir::new(&sessions_dir) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.file_type().is_file() { + continue; + } + if !entry.file_name().to_string_lossy().ends_with(".jsonl") { + continue; + } + let p = entry.path(); + let Ok(c) = std::fs::read_to_string(p) else { + continue; + }; + if c.contains(&marker2) { + resumed_path = Some(p.to_path_buf()); break; } } - std::thread::sleep(Duration::from_millis(50)); + if resumed_path.is_none() { + std::thread::sleep(Duration::from_millis(50)); + } } - if content2.is_empty() { - // last attempt - content2 = std::fs::read_to_string(&path).unwrap(); - new_len = content2.lines().count(); - } - assert!(new_len > orig_len, "rollout file did not grow after resume"); - assert!(content2.contains(&marker), "rollout lost original marker"); + + let resumed_path = resumed_path.expect("No resumed session file found containing the marker2"); + // Resume should have written to a new file, not the original one. + assert_ne!( + resumed_path, path, + "resume should create a new session file" + ); + + let resumed_content = std::fs::read_to_string(&resumed_path).unwrap(); assert!( - content2.contains(&marker2), - "rollout missing resumed marker" + resumed_content.contains(&marker), + "resumed file missing original marker" + ); + assert!( + resumed_content.contains(&marker2), + "resumed file missing resumed marker" + ); + + // Original file should remain unchanged. + let content_after = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + content_after.lines().count(), + orig_len, + "original rollout file should not change on resume" ); } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index aed34dc3a1..07a51f07a6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; @@ -7,8 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::AuthMode; -use codex_login::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -414,12 +414,15 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() { config.model_provider = model_provider; config.preferred_auth_method = AuthMode::ChatGPT; - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_codex_home( + codex_home.path(), + config.preferred_auth_method, + &config.responses_originator_header, + ) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager); let NewConversation { conversation: codex, @@ -495,12 +498,15 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { config.model_provider = model_provider; config.preferred_auth_method = AuthMode::ApiKey; - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_codex_home( + codex_home.path(), + config.preferred_auth_method, + &config.responses_originator_header, + ) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager); let NewConversation { conversation: codex, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 404a88e813..f5e854d275 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ #![expect(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use serde_json::Value; diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs new file mode 100644 index 0000000000..92e43dbfeb --- /dev/null +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -0,0 +1,154 @@ +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::NewConversation; +use codex_core::built_in_model_providers; +use codex_core::protocol::ConversationHistoryResponseEvent; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Build minimal SSE stream with completed marker using the JSON fixture. +fn sse_completed(id: &str) -> String { + core_test_support::load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_conversation_twice_drops_to_first_message() { + // Start a mock server that completes three turns. + let server = MockServer::start().await; + let sse = sse_completed("resp"); + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse.clone(), "text/event-stream"); + + // Expect three calls to /v1/responses – one per user input. + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(3) + .mount(&server) + .await; + + // Configure Codex to use the mock server. + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider.clone(); + let config_for_fork = config.clone(); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config) + .await + .expect("create conversation"); + + // Send three user messages; wait for three completed turns. + for text in ["first", "second", "third"] { + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: text.to_string(), + }], + }) + .await + .unwrap(); + let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + } + + // Request history from the base conversation. + codex.submit(Op::GetHistory).await.unwrap(); + let base_history = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationHistory(_))).await; + + // Capture entries from the base history and compute expected prefixes after each fork. + let entries_after_three = match &base_history { + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { entries, .. }) => { + entries.clone() + } + _ => panic!("expected ConversationHistory event"), + }; + // History layout for this test: + // [0] user instructions, + // [1] environment context, + // [2] "first" user message, + // [3] "second" user message, + // [4] "third" user message. + + // Fork 1: drops the last user message and everything after. + let expected_after_first = vec![ + entries_after_three[0].clone(), + entries_after_three[1].clone(), + entries_after_three[2].clone(), + entries_after_three[3].clone(), + ]; + + // Fork 2: drops the last user message and everything after. + // [0] user instructions, + // [1] environment context, + // [2] "first" user message, + let expected_after_second = vec![ + entries_after_three[0].clone(), + entries_after_three[1].clone(), + entries_after_three[2].clone(), + ]; + + // Fork once with n=1 → drops the last user message and everything after. + let NewConversation { + conversation: codex_fork1, + .. + } = conversation_manager + .fork_conversation(entries_after_three.clone(), 1, config_for_fork.clone()) + .await + .expect("fork 1"); + + codex_fork1.submit(Op::GetHistory).await.unwrap(); + let fork1_history = wait_for_event(&codex_fork1, |ev| { + matches!(ev, EventMsg::ConversationHistory(_)) + }) + .await; + let entries_after_first_fork = match &fork1_history { + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { entries, .. }) => { + assert!(matches!( + fork1_history, + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { ref entries, .. }) if *entries == expected_after_first + )); + entries.clone() + } + _ => panic!("expected ConversationHistory event after first fork"), + }; + + // Fork again with n=1 → drops the (new) last user message, leaving only the first. + let NewConversation { + conversation: codex_fork2, + .. + } = conversation_manager + .fork_conversation(entries_after_first_fork.clone(), 1, config_for_fork.clone()) + .await + .expect("fork 2"); + + codex_fork2.submit(Op::GetHistory).await.unwrap(); + let fork2_history = wait_for_event(&codex_fork2, |ev| { + matches!(ev, EventMsg::ConversationHistory(_)) + }) + .await; + assert!(matches!( + fork2_history, + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { ref entries, .. }) if *entries == expected_after_second + )); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 22aa826699..aabf83bbe3 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -5,6 +5,7 @@ mod client; mod compact; mod exec; mod exec_stream_events; +mod fork_conversation; mod live_cli; mod prompt_caching; mod seatbelt; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 999f807286..11c6ea9acf 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -12,7 +13,6 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::default_user_shell; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 8d4b2c99f2..3395eef954 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::WireApi; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event_with_timeout; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index a425cfa742..6e6938b932 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -3,13 +3,13 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 8403bbde28..cc633277cd 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -404,13 +404,16 @@ impl EventProcessor for EventProcessorWithHumanOutput { println!("{}", line.style(self.green)); } } - FileChange::Delete => { + FileChange::Delete { content } => { let header = format!( "{} {}", format_file_change(change), path.to_string_lossy() ); println!("{}", header.style(self.magenta)); + for line in content.lines() { + println!("{}", line.style(self.red)); + } } FileChange::Update { unified_diff, @@ -560,7 +563,7 @@ fn escape_command(command: &[String]) -> String { fn format_file_change(change: &FileChange) -> &'static str { match change { FileChange::Add { .. } => "A", - FileChange::Delete => "D", + FileChange::Delete { .. } => "D", FileChange::Update { move_path: Some(_), .. } => "R", diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 785272a692..d5991efdad 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -8,19 +8,19 @@ use std::io::Read; use std::path::PathBuf; pub use cli::Cli; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::git_info::get_git_repo_root; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; -use codex_core::util::is_inside_git_repo; -use codex_login::AuthManager; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; @@ -183,7 +183,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any // is using. event_processor.print_config_summary(&config, &prompt); - if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) { + if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() { eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified."); std::process::exit(1); } @@ -191,6 +191,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let conversation_manager = ConversationManager::new(AuthManager::shared( config.codex_home.clone(), config.preferred_auth_method, + config.responses_originator_header.clone(), )); let NewConversation { conversation_id: _, diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index bfcfe922ef..3238c99f24 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -151,7 +151,13 @@ pub fn run( // Use the same tree-walker library that ripgrep uses. We use it directly so // that we can leverage the parallelism it provides. let mut walk_builder = WalkBuilder::new(search_directory); - walk_builder.threads(num_walk_builder_threads); + walk_builder + .threads(num_walk_builder_threads) + // Allow hidden entries. + .hidden(false) + // Don't require git to be present to apply to apply git-related ignore rules. + .require_git(false); + if !exclude.is_empty() { let mut override_builder = OverrideBuilder::new(search_directory); for exclude in exclude { diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index bf04a8e3ff..154924da0c 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +codex-core = { path = "../core" } codex-protocol = { path = "../protocol" } rand = "0.8" reqwest = { version = "0.12", features = ["json", "blocking"] } @@ -16,7 +17,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tempfile = "3" -thiserror = "2.0.12" +thiserror = "2.0.16" tiny_http = "0.12" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/login/src/auth_manager.rs b/codex-rs/login/src/auth_manager.rs deleted file mode 100644 index 5e892b28ce..0000000000 --- a/codex-rs/login/src/auth_manager.rs +++ /dev/null @@ -1,129 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::RwLock; - -use crate::AuthMode; -use crate::CodexAuth; - -/// Internal cached auth state. -#[derive(Clone, Debug)] -struct CachedAuth { - preferred_auth_mode: AuthMode, - auth: Option, -} - -/// Central manager providing a single source of truth for auth.json derived -/// authentication data. It loads once (or on preference change) and then -/// hands out cloned `CodexAuth` values so the rest of the program has a -/// consistent snapshot. -/// -/// External modifications to `auth.json` will NOT be observed until -/// `reload()` is called explicitly. This matches the design goal of avoiding -/// different parts of the program seeing inconsistent auth data mid‑run. -#[derive(Debug)] -pub struct AuthManager { - codex_home: PathBuf, - inner: RwLock, -} - -impl AuthManager { - /// Create a new manager loading the initial auth using the provided - /// preferred auth method. Errors loading auth are swallowed; `auth()` will - /// simply return `None` in that case so callers can treat it as an - /// unauthenticated state. - pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { - let auth = crate::CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) - .ok() - .flatten(); - Self { - codex_home, - inner: RwLock::new(CachedAuth { - preferred_auth_mode, - auth, - }), - } - } - - /// Create an AuthManager with a specific CodexAuth, for testing only. - pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let preferred_auth_mode = auth.mode; - let cached = CachedAuth { - preferred_auth_mode, - auth: Some(auth), - }; - Arc::new(Self { - codex_home: PathBuf::new(), - inner: RwLock::new(cached), - }) - } - - /// Current cached auth (clone). May be `None` if not logged in or load failed. - pub fn auth(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) - } - - /// Preferred auth method used when (re)loading. - pub fn preferred_auth_method(&self) -> AuthMode { - self.inner - .read() - .map(|c| c.preferred_auth_mode) - .unwrap_or(AuthMode::ApiKey) - } - - /// Force a reload using the existing preferred auth method. Returns - /// whether the auth value changed. - pub fn reload(&self) -> bool { - let preferred = self.preferred_auth_method(); - let new_auth = crate::CodexAuth::from_codex_home(&self.codex_home, preferred) - .ok() - .flatten(); - if let Ok(mut guard) = self.inner.write() { - let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); - guard.auth = new_auth; - changed - } else { - false - } - } - - fn auths_equal(a: &Option, b: &Option) -> bool { - match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => a == b, - _ => false, - } - } - - /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { - Arc::new(Self::new(codex_home, preferred_auth_mode)) - } - - /// Attempt to refresh the current auth token (if any). On success, reload - /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token(&self) -> std::io::Result> { - let auth = match self.auth() { - Some(a) => a, - None => return Ok(None), - }; - match auth.refresh_token().await { - Ok(token) => { - // Reload to pick up persisted changes. - self.reload(); - Ok(Some(token)) - } - Err(e) => Err(e), - } - } - - /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) - /// if a file was removed, Ok(false) if no auth file existed. On success, - /// reloads the in‑memory auth cache so callers immediately observe the - /// unauthenticated state. - pub fn logout(&self) -> std::io::Result { - let removed = crate::logout(&self.codex_home)?; - // Always reload to clear any cached auth (even if file absent). - self.reload(); - Ok(removed) - } -} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 6d5297ea09..a737af22c8 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,693 +1,21 @@ -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use std::env; -use std::fs::File; -use std::fs::OpenOptions; -use std::fs::remove_file; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; - -pub use crate::server::LoginServer; -pub use crate::server::ServerOptions; -pub use crate::server::ShutdownHandle; -pub use crate::server::run_login_server; -pub use crate::token_data::TokenData; -use crate::token_data::parse_id_token; - -mod auth_manager; mod pkce; mod server; -mod token_data; -pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -pub use auth_manager::AuthManager; +pub use server::LoginServer; +pub use server::ServerOptions; +pub use server::ShutdownHandle; +pub use server::run_login_server; + +// Re-export commonly used auth types and helpers from codex-core for compatibility +pub use codex_core::AuthManager; +pub use codex_core::CodexAuth; +pub use codex_core::auth::AuthDotJson; +pub use codex_core::auth::CLIENT_ID; +pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +pub use codex_core::auth::get_auth_file; +pub use codex_core::auth::login_with_api_key; +pub use codex_core::auth::logout; +pub use codex_core::auth::try_read_auth_json; +pub use codex_core::auth::write_auth_json; +pub use codex_core::token_data::TokenData; pub use codex_protocol::mcp_protocol::AuthMode; - -#[derive(Debug, Clone)] -pub struct CodexAuth { - pub mode: AuthMode, - - api_key: Option, - auth_dot_json: Arc>>, - auth_file: PathBuf, -} - -impl PartialEq for CodexAuth { - fn eq(&self, other: &Self) -> bool { - self.mode == other.mode - } -} - -impl CodexAuth { - pub fn from_api_key(api_key: &str) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(None)), - } - } - - pub async fn refresh_token(&self) -> Result { - let token_data = self - .get_current_token_data() - .ok_or(std::io::Error::other("Token data is not available."))?; - let token = token_data.refresh_token; - - let refresh_response = try_refresh_token(token) - .await - .map_err(std::io::Error::other)?; - - let updated = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - if let Ok(mut auth_lock) = self.auth_dot_json.lock() { - *auth_lock = Some(updated.clone()); - } - - let access = match updated.tokens { - Some(t) => t.access_token, - None => { - return Err(std::io::Error::other( - "Token data is not available after refresh.", - )); - } - }; - Ok(access) - } - - /// Loads the available auth information from the auth.json or - /// OPENAI_API_KEY environment variable. - pub fn from_codex_home( - codex_home: &Path, - preferred_auth_method: AuthMode, - ) -> std::io::Result> { - load_auth(codex_home, true, preferred_auth_method) - } - - pub async fn get_token_data(&self) -> Result { - let auth_dot_json: Option = self.get_current_auth_json(); - match auth_dot_json { - Some(AuthDotJson { - tokens: Some(mut tokens), - last_refresh: Some(last_refresh), - .. - }) => { - if last_refresh < Utc::now() - chrono::Duration::days(28) { - let refresh_response = tokio::time::timeout( - Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone()), - ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - })? - .map_err(std::io::Error::other)?; - - let updated_auth_dot_json = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - tokens = updated_auth_dot_json - .tokens - .clone() - .ok_or(std::io::Error::other( - "Token data is not available after refresh.", - ))?; - - #[expect(clippy::unwrap_used)] - let mut auth_lock = self.auth_dot_json.lock().unwrap(); - *auth_lock = Some(updated_auth_dot_json); - } - - Ok(tokens) - } - _ => Err(std::io::Error::other("Token data is not available.")), - } - } - - pub async fn get_token(&self) -> Result { - match self.mode { - AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT => { - let id_token = self.get_token_data().await?.access_token; - - Ok(id_token) - } - } - } - - pub fn get_account_id(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.account_id.clone()) - } - - pub fn get_plan_type(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) - } - - fn get_current_auth_json(&self) -> Option { - #[expect(clippy::unwrap_used)] - self.auth_dot_json.lock().unwrap().clone() - } - - fn get_current_token_data(&self) -> Option { - self.get_current_auth_json().and_then(|t| t.tokens.clone()) - } - - /// Consider this private to integration tests. - pub fn create_dummy_chatgpt_auth_for_testing() -> Self { - let auth_dot_json = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: Default::default(), - access_token: "Access Token".to_string(), - refresh_token: "test".to_string(), - account_id: Some("account_id".to_string()), - }), - last_refresh: Some(Utc::now()), - }; - - let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); - Self { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), - auth_dot_json, - } - } -} - -fn load_auth( - codex_home: &Path, - include_env_var: bool, - preferred_auth_method: AuthMode, -) -> std::io::Result> { - // First, check to see if there is a valid auth.json file. If not, we fall - // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable - // (if it is set). - let auth_file = get_auth_file(codex_home); - let auth_dot_json = match try_read_auth_json(&auth_file) { - Ok(auth) => auth, - // If auth.json does not exist, try to read the OPENAI_API_KEY from the - // environment variable. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { - return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), - None => Ok(None), - }; - } - // Though if auth.json exists but is malformed, do not fall back to the - // env var because the user may be expecting to use AuthMode::ChatGPT. - Err(e) => { - return Err(e); - } - }; - - let AuthDotJson { - openai_api_key: auth_json_api_key, - tokens, - last_refresh, - } = auth_dot_json; - - // If the auth.json has an API key AND does not appear to be on a plan that - // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. - if let Some(api_key) = &auth_json_api_key { - // Should any of these be AuthMode::ChatGPT with the api_key set? - // Does AuthMode::ChatGPT indicate that there is an auth.json that is - // "refreshable" even if we are using the API key for auth? - match &tokens { - Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { - return Ok(Some(CodexAuth::from_api_key(api_key))); - } else { - // Ignore the API key and fall through to ChatGPT auth. - } - } - None => { - // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated - // auth.json by hand. Either way, let's assume they are trying - // to use their API key. - return Ok(Some(CodexAuth::from_api_key(api_key))); - } - } - } - - // For the AuthMode::ChatGPT variant, perhaps neither api_key nor - // openai_api_key should exist? - Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file, - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - openai_api_key: None, - tokens, - last_refresh, - }))), - })) -} - -fn read_openai_api_key_from_env() -> Option { - env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .filter(|s| !s.is_empty()) -} - -pub fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - -/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` -/// if a file was removed, `Ok(false)` if no auth file was present. -pub fn logout(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match remove_file(&auth_file) { - Ok(_) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } -} - -pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { - let auth_dot_json = AuthDotJson { - openai_api_key: Some(api_key.to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(codex_home), &auth_dot_json) -} - -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) -} - -async fn update_tokens( - auth_file: &Path, - id_token: String, - access_token: Option, - refresh_token: Option, -) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; - - let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; - if let Some(access_token) = access_token { - tokens.access_token = access_token.to_string(); - } - if let Some(refresh_token) = refresh_token { - tokens.refresh_token = refresh_token.to_string(); - } - auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; - Ok(auth_dot_json) -} - -async fn try_refresh_token(refresh_token: String) -> std::io::Result { - let refresh_request = RefreshRequest { - client_id: CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - scope: "openid profile email", - }; - - let client = reqwest::Client::new(); - let response = client - .post("https://auth.openai.com/oauth/token") - .header("Content-Type", "application/json") - .json(&refresh_request) - .send() - .await - .map_err(std::io::Error::other)?; - - if response.status().is_success() { - let refresh_response = response - .json::() - .await - .map_err(std::io::Error::other)?; - Ok(refresh_response) - } else { - Err(std::io::Error::other(format!( - "Failed to refresh token: {}", - response.status() - ))) - } -} - -#[derive(Serialize)] -struct RefreshRequest { - client_id: &'static str, - grant_type: &'static str, - refresh_token: String, - scope: &'static str, -} - -#[derive(Deserialize, Clone)] -struct RefreshResponse { - id_token: String, - access_token: Option, - refresh_token: Option, -} - -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; - use base64::Engine; - use pretty_assertions::assert_eq; - use serde_json::json; - use tempfile::tempdir; - - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - - #[test] - fn writes_api_key_and_loads_auth() { - let dir = tempdir().unwrap(); - login_with_api_key(dir.path(), "sk-test-key").unwrap(); - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key.as_deref(), Some("sk-test-key")); - } - - #[test] - fn loads_from_env_var_if_env_var_exists() { - let dir = tempdir().unwrap(); - - let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR); - - if let Ok(env_var) = env_var { - let auth = load_auth(dir.path(), true, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some(env_var)); - } - } - - #[tokio::test] - async fn roundtrip_auth_dot_json() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let file = get_auth_file(codex_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); - - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); - } - - #[tokio::test] - async fn pro_account_with_no_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in - /// [`TokenData::is_plan_that_should_use_api_key`], it should use - /// [`AuthMode::ChatGPT`]. - #[tokio::test] - async fn pro_account_with_api_key_still_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise - /// account, then it should use [`AuthMode::ApiKey`]. - #[tokio::test] - async fn enterprise_account_with_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "enterprise".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(Some("sk-test-key".to_string()), api_key); - assert_eq!(AuthMode::ApiKey, mode); - - let guard = auth_dot_json.lock().expect("should unwrap"); - assert!(guard.is_none(), "auth_dot_json should be None"); - } - - struct AuthFileParams { - openai_api_key: Option, - chatgpt_plan_type: String, - } - - fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - // Create a minimal valid JWT for the id_token field. - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } - }); - let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); - let header_b64 = b64(&serde_json::to_vec(&header)?); - let payload_b64 = b64(&serde_json::to_vec(&payload)?); - let signature_b64 = b64(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let auth_json_data = json!({ - "OPENAI_API_KEY": params.openai_api_key, - "tokens": { - "id_token": fake_jwt, - "access_token": "test-access-token", - "refresh_token": "test-refresh-token" - }, - "last_refresh": LAST_REFRESH, - }); - let auth_json = serde_json::to_string_pretty(&auth_json_data)?; - std::fs::write(auth_file, auth_json)?; - - Ok(fake_jwt) - } - - #[test] - fn id_token_info_handles_missing_fields() { - // Payload without email or plan should yield None values. - let header = serde_json::json!({"alg": "none", "typ": "JWT"}); - let payload = serde_json::json!({"sub": "123"}); - let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&header).unwrap()); - let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); - let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_id_token(&jwt).expect("should parse"); - assert!(info.email.is_none()); - assert!(info.chatgpt_plan_type.is_none()); - } - - #[tokio::test] - async fn loads_api_key_from_auth_json() { - let dir = tempdir().unwrap(); - let auth_file = dir.path().join("auth.json"); - std::fs::write( - auth_file, - r#" - { - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null - } - "#, - ) - .unwrap(); - - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some("sk-test-key".to_string())); - - assert!(auth.get_token_data().await.is_err()); - } - - #[test] - fn logout_removes_auth_file() -> Result<(), std::io::Error> { - let dir = tempdir()?; - login_with_api_key(dir.path(), "sk-test-key")?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - Ok(()) - } -} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 6ffc86219d..5cc08d4205 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -5,12 +5,14 @@ use std::path::PathBuf; use std::sync::Arc; use std::thread; -use crate::AuthDotJson; -use crate::get_auth_file; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; +use codex_core::auth::AuthDotJson; +use codex_core::auth::get_auth_file; +use codex_core::token_data::TokenData; +use codex_core::token_data::parse_id_token; use rand::RngCore; use tiny_http::Header; use tiny_http::Request; @@ -374,10 +376,8 @@ async fn persist_tokens_async( if let Some(key) = api_key { auth.openai_api_key = Some(key); } - let tokens = auth - .tokens - .get_or_insert_with(crate::token_data::TokenData::default); - tokens.id_token = crate::token_data::parse_id_token(&id_token).map_err(io::Error::other)?; + let tokens = auth.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(io::Error::other)?; // Persist chatgpt_account_id if present in claims if let Some(acc) = jwt_auth_claims(&id_token) .get("chatgpt_account_id") @@ -392,14 +392,14 @@ async fn persist_tokens_async( tokens.refresh_token = rt; } auth.last_refresh = Some(Utc::now()); - super::write_auth_json(&auth_file, &auth) + codex_core::auth::write_auth_json(&auth_file, &auth) }) .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } fn read_or_default(path: &Path) -> AuthDotJson { - match super::try_read_auth_json(path) { + match codex_core::auth::try_read_auth_json(path) { Ok(auth) => auth, Err(_) => AuthDotJson { openai_api_key: None, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index aae463ad92..72474d9866 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -16,7 +17,6 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ReviewDecision; -use codex_login::AuthManager; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; @@ -31,9 +31,9 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::json_to_toml::json_to_toml; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_core::auth::CLIENT_ID; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; -use codex_login::CLIENT_ID; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6be60151d2..bf5dbf9804 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -10,10 +10,10 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::mcp_protocol::ClientRequest; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::Submission; -use codex_login::AuthManager; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::ClientRequest as McpClientRequest; @@ -53,8 +53,11 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = - AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + config.preferred_auth_method, + config.responses_originator_header.clone(), + ); let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); let codex_message_processor = CodexMessageProcessor::new( auth_manager, diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/mcp-server/tests/suite/auth.rs index 533cb90313..3bf972842a 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/mcp-server/tests/suite/auth.rs @@ -1,6 +1,6 @@ use std::path::Path; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/mcp-server/tests/suite/login.rs index 7a796c01a0..ba5021c1fa 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/mcp-server/tests/suite/login.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; use codex_protocol::mcp_protocol::GetAuthStatusParams; diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index 63399acc65..326f6cf57f 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -17,22 +17,6 @@ pub enum ParsedCommand { query: Option, path: Option, }, - Format { - cmd: String, - tool: Option, - targets: Option>, - }, - Test { - cmd: String, - }, - Lint { - cmd: String, - tool: Option, - targets: Option>, - }, - Noop { - cmd: String, - }, Unknown { cmd: String, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f6cbe07b8..315ec09f74 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -869,7 +869,9 @@ pub enum FileChange { Add { content: String, }, - Delete, + Delete { + content: String, + }, Update { unified_diff: String, move_path: Option, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 8e5fdf1ae4..9a115d28b0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -22,7 +22,6 @@ workspace = true [dependencies] anyhow = "1" -arboard = "3" async-stream = "0.3.6" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } @@ -49,6 +48,7 @@ image = { version = "^0.25.6", default-features = false, features = [ "jpeg", "png", ] } +itertools = "0.14.0" lazy_static = "1" mcp-types = { path = "../mcp-types" } once_cell = "1" @@ -87,10 +87,16 @@ unicode-segmentation = "1.12.0" unicode-width = "0.1" url = "2" uuid = "1" +pathdiff = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" +# Clipboard support via `arboard` is not available on Android/Termux. +# Only include it for non-Android targets so the crate builds on Android. +[target.'cfg(not(target_os = "android"))'.dependencies] +arboard = "3" + [dev-dependencies] chrono = { version = "0.4", features = ["serde"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6d107d67a0..439dbcdfab 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,10 +7,10 @@ use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::TokenUsage; -use codex_login::AuthManager; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -43,6 +43,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, + has_emitted_history_lines: bool, pub(crate) enhanced_keys_supported: bool, @@ -91,6 +92,7 @@ impl App { transcript_lines: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), }; @@ -177,27 +179,28 @@ impl App { ); tui.frame_requester().schedule_frame(); } - AppEvent::InsertHistoryLines(lines) => { - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_lines(lines.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_lines.extend(lines.clone()); - if self.overlay.is_some() { - self.deferred_history_lines.extend(lines); - } else { - tui.insert_history_lines(lines); - } - } AppEvent::InsertHistoryCell(cell) => { - let cell_transcript = cell.transcript_lines(); + let mut cell_transcript = cell.transcript_lines(); + if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() { + cell_transcript.insert(0, Line::from("")); + } if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_lines(cell_transcript.clone()); tui.frame_requester().schedule_frame(); } self.transcript_lines.extend(cell_transcript.clone()); - let display = cell.display_lines(); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); if !display.is_empty() { + // Only insert a separating blank line for new cells that are not + // part of an ongoing stream. Streaming continuations should not + // accrue extra blank lines between chunks. + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 439edbfdc7..1896f0a46e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,7 +1,6 @@ use codex_core::protocol::ConversationHistoryResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; -use ratatui::text::Line; use crate::history_cell::HistoryCell; @@ -40,7 +39,6 @@ pub(crate) enum AppEvent { /// Result of computing a `/diff` command. DiffResult(String), - InsertHistoryLines(Vec>), InsertHistoryCell(Box), StartCommitAnimation, diff --git a/codex-rs/tui/src/backtrack_helpers.rs b/codex-rs/tui/src/backtrack_helpers.rs index c275519c20..b006ae38eb 100644 --- a/codex-rs/tui/src/backtrack_helpers.rs +++ b/codex-rs/tui/src/backtrack_helpers.rs @@ -113,10 +113,9 @@ fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, #[cfg(test)] mod tests { use super::*; - use ratatui::text::Span; fn line(s: &str) -> Line<'static> { - Line::from(Span::raw(s.to_string())) + s.to_string().into() } fn transcript_with_users(count: usize) -> Vec> { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 49a3478582..c873ac8d55 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -27,6 +27,7 @@ use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use crate::bottom_pane::paste_burst::FlushResult; use crate::slash_command::SlashCommand; use codex_protocol::custom_prompts::CustomPrompt; @@ -223,7 +224,7 @@ impl ChatComposer { let placeholder = format!("[Pasted Content {char_count} chars]"); self.textarea.insert_element(&placeholder); self.pending_pastes.push((placeholder, pasted)); - } else if self.handle_paste_image_path(pasted.clone()) { + } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { self.textarea.insert_str(" "); } else { self.textarea.insert_str(&pasted); @@ -298,12 +299,7 @@ impl ChatComposer { } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - let now = Instant::now(); - if let Some(pasted) = self.paste_burst.flush_if_due(now) { - let _ = self.handle_paste(pasted); - return true; - } - false + self.handle_paste_burst_flush(Instant::now()) } pub(crate) fn is_in_paste_burst(&self) -> bool { @@ -396,9 +392,11 @@ impl ChatComposer { KeyEvent { code: KeyCode::Tab, .. } => { + // Ensure popup filtering/selection reflects the latest composer text + // before applying completion. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); if let Some(sel) = popup.selected_item() { - let first_line = self.textarea.text().lines().next().unwrap_or(""); - match sel { CommandItem::Builtin(cmd) => { let starts_with_cmd = first_line @@ -837,7 +835,12 @@ impl ChatComposer { } self.pending_pastes.clear(); + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } if !text.is_empty() { self.history.record_local_submission(&text); } @@ -848,15 +851,36 @@ impl ChatComposer { } } + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { + match self.paste_burst.flush_if_due(now) { + FlushResult::Paste(pasted) => { + self.handle_paste(pasted); + true + } + FlushResult::Typed(ch) => { + // Mirror insert_str() behavior so popups stay in sync when a + // pending fast char flushes as normal typed input. + self.textarea.insert_str(ch.to_string().as_str()); + // Keep popup sync consistent with key handling: prefer slash popup; only + // sync file popup when slash popup is NOT active. + self.sync_command_popup(); + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + } else { + self.sync_file_search_popup(); + } + true + } + FlushResult::None => false, + } + } + /// Handle generic Input events that modify the textarea content. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { // If we have a buffered non-bracketed paste burst and enough time has // elapsed since the last char, flush it before handling a new input. let now = Instant::now(); - if let Some(pasted) = self.paste_burst.flush_if_due(now) { - // Reuse normal paste path (handles large-paste placeholders). - self.handle_paste(pasted); - } + self.handle_paste_burst_flush(now); // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) @@ -1238,9 +1262,9 @@ impl WidgetRef for ChatComposer { let key_hint_style = Style::default().fg(Color::Cyan); let mut hint = if self.ctrl_c_quit_hint { vec![ - Span::from(" "), + " ".into(), "Ctrl+C again".set_style(key_hint_style), - Span::from(" to quit"), + " to quit".into(), ] } else { let newline_hint_key = if self.use_shift_enter_hint { @@ -1249,28 +1273,28 @@ impl WidgetRef for ChatComposer { "Ctrl+J" }; vec![ - Span::from(" "), + " ".into(), "⏎".set_style(key_hint_style), - Span::from(" send "), + " send ".into(), newline_hint_key.set_style(key_hint_style), - Span::from(" newline "), + " newline ".into(), "Ctrl+T".set_style(key_hint_style), - Span::from(" transcript "), + " transcript ".into(), "Ctrl+C".set_style(key_hint_style), - Span::from(" quit"), + " quit".into(), ] }; if !self.ctrl_c_quit_hint && self.esc_backtrack_hint { - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push("Esc".set_style(key_hint_style)); - hint.push(Span::from(" edit prev")); + hint.push(" edit prev".into()); } // Append token/context usage info to the footer hints when available. if let Some(token_usage_info) = &self.token_usage_info { let token_usage = &token_usage_info.total_token_usage; - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push( Span::from(format!("{} tokens used", token_usage.blended_total())) .style(Style::default().add_modifier(Modifier::DIM)), @@ -1285,7 +1309,7 @@ impl WidgetRef for ChatComposer { } else { 100 }; - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push( Span::from(format!("{percent_remaining}% context left")) .style(Style::default().add_modifier(Modifier::DIM)), @@ -1520,6 +1544,33 @@ mod tests { } } + #[test] + fn empty_enter_returns_none() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Ensure composer is empty and press Enter. + assert!(composer.textarea.text().is_empty()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::None => {} + other => panic!("expected None for empty enter, got: {other:?}"), + } + } + #[test] fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { use crossterm::event::KeyCode; @@ -1640,6 +1691,66 @@ mod tests { } } + #[test] + fn slash_popup_model_first_for_mo_ui() { + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/mo" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 4)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| f.render_widget_ref(composer, f.area())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + // Visual snapshot should show the slash popup with /model as the first entry. + assert_snapshot!("slash_popup_mo", terminal.backend()); + } + + #[test] + fn slash_popup_model_first_for_mo_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "model") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/mo'") + } + None => panic!("no selected command for '/mo'"), + }, + _ => panic!("slash popup not active after typing '/mo'"), + } + } + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; @@ -1931,7 +2042,7 @@ mod tests { composer.handle_paste(paste.clone()); composer .textarea - .set_cursor((placeholder.len() - pos_from_end) as usize); + .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( composer.textarea.text().contains(&placeholder), diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 4e60e7eaa1..a5961e3188 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -195,7 +195,15 @@ impl WidgetRef for CommandPopup { }) .collect() }; - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); + render_rows( + area, + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + false, + "no matches", + ); } } @@ -238,6 +246,20 @@ mod tests { } } + #[test] + fn model_is_first_suggestion_for_mo() { + let mut popup = CommandPopup::new(Vec::new()); + popup.on_composer_text_change("/mo".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/model' for '/mo'") + } + None => panic!("expected at least one match for '/mo'"), + } + } + #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index f046a2f144..65dfe6933b 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -132,11 +132,20 @@ impl WidgetRef for &FileSearchPopup { .collect() }; - if self.waiting && rows_all.is_empty() { - // Render a minimal waiting stub using the shared renderer (no rows -> "no matches"). - render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS, false); + let empty_message = if self.waiting { + "loading..." } else { - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); - } + "no matches" + }; + + render_rows( + area, + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + false, + empty_message, + ); } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 74b5274f26..357639693b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -3,8 +3,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Modifier; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; @@ -42,7 +41,7 @@ pub(crate) struct ListSelectionView { impl ListSelectionView { fn dim_prefix_span() -> Span<'static> { - Span::styled("▌ ", Style::default().add_modifier(Modifier::DIM)) + "▌ ".dim() } fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) { @@ -162,13 +161,8 @@ impl BottomPaneView for ListSelectionView { height: 1, }; - let title_spans: Vec> = vec![ - Self::dim_prefix_span(), - Span::styled( - self.title.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - ]; + let title_spans: Vec> = + vec![Self::dim_prefix_span(), self.title.clone().bold()]; let title_para = Paragraph::new(Line::from(title_spans)); title_para.render(title_area, buf); @@ -180,10 +174,8 @@ impl BottomPaneView for ListSelectionView { width: area.width, height: 1, }; - let subtitle_spans: Vec> = vec![ - Self::dim_prefix_span(), - Span::styled(sub.clone(), Style::default().add_modifier(Modifier::DIM)), - ]; + let subtitle_spans: Vec> = + vec![Self::dim_prefix_span(), sub.clone().dim()]; let subtitle_para = Paragraph::new(Line::from(subtitle_spans)); subtitle_para.render(subtitle_area, buf); // Render the extra spacer line with the dimmed prefix to align with title/subtitle @@ -230,7 +222,15 @@ impl BottomPaneView for ListSelectionView { }) .collect(); if rows_area.height > 0 { - render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true); + render_rows( + rows_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + true, + "no matches", + ); } if let Some(hint) = &self.footer_hint { @@ -240,10 +240,7 @@ impl BottomPaneView for ListSelectionView { width: area.width, height: 1, }; - let footer_para = Paragraph::new(Line::from(Span::styled( - hint.clone(), - Style::default().add_modifier(Modifier::DIM), - ))); + let footer_para = Paragraph::new(hint.clone().dim()); footer_para.render(footer_area, buf); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b94db79f1c..5ab32849b1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -100,53 +100,47 @@ impl BottomPane { } pub fn desired_height(&self, width: u16) -> u16 { - let top_margin = if self.active_view.is_some() { 0 } else { 1 }; + // Always reserve one blank row above the pane for visual spacing. + let top_margin = 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 base = match self.active_view.as_ref() { + Some(view) => view.desired_height(width), + None => self.composer.desired_height(width).saturating_add( + self.status + .as_ref() + .map_or(0, |status| status.desired_height(width)), + ), }; - // 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; 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 - } + // At small heights, bottom pane takes the entire height. + let (top_margin, bottom_margin) = if area.height <= BottomPane::BOTTOM_PAD_LINES + 1 { + (0, 0) } else { - 0 + (1, BottomPane::BOTTOM_PAD_LINES) }; - let [_, status, content, _] = Layout::vertical([ - Constraint::Max(top_margin), - Constraint::Max(status_height), - Constraint::Min(1), - Constraint::Max(BottomPane::BOTTOM_PAD_LINES), - ]) - .areas(area); - - [status, content] + let area = Rect { + x: area.x, + y: area.y + top_margin, + width: area.width, + height: area.height - top_margin - bottom_margin, + }; + match self.active_view.as_ref() { + Some(_) => [Rect::ZERO, area], + None => { + let status_height = self + .status + .as_ref() + .map_or(0, |status| status.desired_height(area.width)); + Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area) + } + } } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { @@ -705,7 +699,7 @@ mod tests { pane.set_task_running(true); - // Height=2 → composer visible; status is hidden to preserve composer. Spacer may collapse. + // Height=2 → status on one row, composer on the other. let area2 = Rect::new(0, 0, 20, 2); let mut buf2 = Buffer::empty(area2); (&pane).render_ref(area2, &mut buf2); @@ -721,8 +715,8 @@ mod tests { "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" + row0.contains("Working") || row1.contains("Working"), + "expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}" ); // Height=1 → no padding; single row is the composer (status hidden). diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index b353d8677d..d507dcae37 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -35,6 +35,12 @@ pub(crate) struct RetroGrab { pub grabbed: String, } +pub(crate) enum FlushResult { + Paste(String), + Typed(char), + None, +} + impl PasteBurst { /// Recommended delay to wait between simulated keypresses (or before /// scheduling a UI tick) so that a pending fast keystroke is flushed @@ -95,24 +101,24 @@ impl PasteBurst { /// now emit that char as normal typed input. /// /// Returns None if the timeout has not elapsed or there is nothing to flush. - pub fn flush_if_due(&mut self, now: Instant) -> Option { + pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { let timed_out = self .last_plain_char_time .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); if timed_out && self.is_active_internal() { self.active = false; let out = std::mem::take(&mut self.buffer); - Some(out) + FlushResult::Paste(out) } else if timed_out { // If we were saving a single fast char and no burst followed, // flush it as normal typed input. if let Some((ch, _at)) = self.pending_first_char.take() { - Some(ch.to_string()) + FlushResult::Typed(ch) } else { - None + FlushResult::None } } else { - None + FlushResult::None } } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index a83ec15182..3852d10bc1 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -4,6 +4,7 @@ use ratatui::prelude::Constraint; 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::Block; @@ -35,13 +36,13 @@ pub(crate) fn render_rows( state: &ScrollState, max_results: usize, _dim_non_selected: bool, + empty_message: &str, ) { let mut rows: Vec = Vec::new(); if rows_all.is_empty() { - rows.push(Row::new(vec![Cell::from(Line::from(Span::styled( - "no matches", - Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM), - )))])); + rows.push(Row::new(vec![Cell::from(Line::from( + empty_message.dim().italic(), + ))])); } else { let max_rows_from_area = area.height as usize; let visible_rows = max_results @@ -79,23 +80,20 @@ pub(crate) fn render_rows( if let Some(idxs) = match_indices.as_ref() { let mut idx_iter = idxs.iter().peekable(); for (char_idx, ch) in name.chars().enumerate() { - let mut style = Style::default(); if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); - style = style.add_modifier(Modifier::BOLD); + spans.push(ch.to_string().bold()); + } else { + spans.push(ch.to_string().into()); } - spans.push(Span::styled(ch.to_string(), style)); } } else { - spans.push(Span::raw(name.clone())); + spans.push(name.clone().into()); } if let Some(desc) = description.as_ref() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - desc.clone(), - Style::default().add_modifier(Modifier::DIM), - )); + spans.push(" ".into()); + spans.push(desc.clone().dim()); } let mut cell = Cell::from(Line::from(spans)); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 0000000000..63f9b3a964 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +"▌/mo " +"▌ " +"▌/model choose what model and reasoning effort to use " +"▌/mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2ad8c44007..cb6ef501a1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -245,7 +245,6 @@ impl TextArea { } => self.delete_backward_word(), KeyEvent { code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, .. } | KeyEvent { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4695c6566c..f86658a8aa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -101,7 +101,6 @@ pub(crate) struct ChatWidget { // Stream lifecycle controller stream: StreamController, running_commands: HashMap, - pending_exec_completions: Vec<(Vec, Vec, CommandOutput)>, task_complete_pending: bool, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, @@ -113,7 +112,6 @@ pub(crate) struct ChatWidget { frame_requester: FrameRequester, // 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, } @@ -193,10 +191,12 @@ impl ChatWidget { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { - self.add_to_history(history_cell::new_reasoning_block( + for cell in history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), &self.config, - )); + ) { + self.add_boxed_history(cell); + } } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); @@ -333,6 +333,7 @@ impl ChatWidget { auto_approved: event.auto_approved, }, event.changes, + &self.config.cwd, )); } @@ -442,14 +443,14 @@ impl ChatWidget { self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. - // Reset the exec header grouping so the next exec shows its header. - self.last_history_was_exec = false; self.flush_interrupt_queue(); } } #[inline] fn handle_streaming_delta(&mut self, delta: String) { + // Before streaming agent content, flush any active exec cell group. + self.flush_active_exec_cell(); let sink = AppEventHistorySink(self.app_event_tx.clone()); self.stream.begin(&sink); self.stream.push_and_maybe_commit(&delta, &sink); @@ -462,31 +463,29 @@ impl ChatWidget { Some(rc) => (rc.command, rc.parsed_cmd), None => (vec![ev.call_id.clone()], Vec::new()), }; - self.pending_exec_completions.push(( - command, - parsed, - CommandOutput { - exit_code: ev.exit_code, - stdout: ev.stdout.clone(), - stderr: ev.stderr.clone(), - formatted_output: ev.formatted_output.clone(), - }, - )); - if self.running_commands.is_empty() { - self.active_exec_cell = None; - let pending = std::mem::take(&mut self.pending_exec_completions); - for (command, parsed, output) in pending { - let include_header = !self.last_history_was_exec; - let cell = history_cell::new_completed_exec_command( - command, - parsed, - output, - include_header, - ev.duration, - ); - self.add_to_history(cell); - self.last_history_was_exec = true; + if self.active_exec_cell.is_none() { + // This should have been created by handle_exec_begin_now, but in case it wasn't, + // create it now. + self.active_exec_cell = Some(history_cell::new_active_exec_command( + ev.call_id.clone(), + command, + parsed, + )); + } + if let Some(cell) = self.active_exec_cell.as_mut() { + cell.complete_call( + &ev.call_id, + CommandOutput { + exit_code: ev.exit_code, + stdout: ev.stdout.clone(), + stderr: ev.stderr.clone(), + formatted_output: ev.formatted_output.clone(), + }, + ev.duration, + ); + if cell.should_flush() { + self.flush_active_exec_cell(); } } } @@ -495,9 +494,9 @@ impl ChatWidget { &mut self, event: codex_core::protocol::PatchApplyEndEvent, ) { - if event.success { - self.add_to_history(history_cell::new_patch_apply_success(event.stdout)); - } else { + // If the patch was successful, just let the "Edited" block stand. + // Otherwise, add a failure block. + if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } } @@ -523,6 +522,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_event( PatchEventType::ApprovalRequest, ev.changes.clone(), + &self.config.cwd, )); let request = ApprovalRequest::ApplyPatch { @@ -543,19 +543,28 @@ impl ChatWidget { parsed_cmd: ev.parsed_cmd.clone(), }, ); - // Accumulate parsed commands into a single active Exec cell so they stack - match self.active_exec_cell.as_mut() { - Some(exec) => { - exec.parsed.extend(ev.parsed_cmd); - } - _ => { - let include_header = !self.last_history_was_exec; + if let Some(exec) = &self.active_exec_cell { + if let Some(new_exec) = exec.with_added_call( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd.clone(), + ) { + self.active_exec_cell = Some(new_exec); + } else { + // Make a new cell. + self.flush_active_exec_cell(); self.active_exec_cell = Some(history_cell::new_active_exec_command( - ev.command, - ev.parsed_cmd, - include_header, + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd.clone(), )); } + } else { + self.active_exec_cell = Some(history_cell::new_active_exec_command( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd.clone(), + )); } // Request a redraw so the working header and command list are visible immediately. @@ -585,7 +594,7 @@ impl ChatWidget { Constraint::Max( self.active_exec_cell .as_ref() - .map_or(0, |c| c.desired_height(area.width)), + .map_or(0, |c| c.desired_height(area.width) + 1), ), Constraint::Min(self.bottom_pane.desired_height(area.width)), ]) @@ -627,13 +636,11 @@ impl ChatWidget { last_token_usage: TokenUsage::default(), stream: StreamController::new(config), running_commands: HashMap::new(), - pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), 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, } @@ -673,13 +680,11 @@ impl ChatWidget { last_token_usage: TokenUsage::default(), stream: StreamController::new(config), running_commands: HashMap::new(), - pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), 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, } @@ -690,7 +695,7 @@ impl ChatWidget { + self .active_exec_cell .as_ref() - .map_or(0, |c| c.desired_height(width)) + .map_or(0, |c| c.desired_height(width) + 1) } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { @@ -766,7 +771,7 @@ impl ChatWidget { fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( - "'/'{}' is disabled while a task is in progress.", + "'/{}' is disabled while a task is in progress.", cmd.command() ); self.add_to_history(history_cell::new_error_event(message)); @@ -795,7 +800,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { + if let Err(e) = codex_core::auth::logout(&self.config.codex_home) { tracing::error!("failed to logout: {e}"); } self.app_event_tx.send(AppEvent::ExitRequest); @@ -891,25 +896,20 @@ impl ChatWidget { fn flush_active_exec_cell(&mut self) { if let Some(active) = self.active_exec_cell.take() { - self.last_history_was_exec = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(active))); } } fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { - // Only break exec grouping if the cell renders visible lines. - let has_display_lines = !cell.display_lines().is_empty(); - self.flush_active_exec_cell(); - if has_display_lines { - self.last_history_was_exec = false; - } - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.add_boxed_history(Box::new(cell)); } fn add_boxed_history(&mut self, cell: Box) { - self.flush_active_exec_cell(); + if !cell.display_lines(u16::MAX).is_empty() { + // Only break exec grouping if the cell renders visible lines. + self.flush_active_exec_cell(); + } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } @@ -1028,7 +1028,6 @@ impl ChatWidget { let cell = cell.into_failed(); // Insert finalized exec into history and keep grouping consistent. self.add_to_history(cell); - self.last_history_was_exec = true; } } @@ -1083,6 +1082,7 @@ impl ChatWidget { let is_current = preset.model == current_model && preset.effort == current_effort; let model_slug = preset.model.to_string(); let effort = preset.effort; + let current_model = current_model.clone(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, @@ -1094,6 +1094,13 @@ impl ChatWidget { })); tx.send(AppEvent::UpdateModel(model_slug.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort)); + tracing::info!( + "New model: {}, New effort: {}, Current model: {}, Current effort: {}", + model_slug.clone(), + effort, + current_model, + current_effort + ); })]; items.push(SelectionItem { name, @@ -1284,6 +1291,9 @@ impl WidgetRef for &ChatWidget { let [active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if let Some(cell) = &self.active_exec_cell { + let mut active_cell_area = active_cell_area; + active_cell_area.y += 1; + active_cell_area.height -= 1; cell.render_ref(active_cell_area, buf); } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap new file mode 100644 index 0000000000..3ed1e07507 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&approved_lines) +--- +• Change Approved foo.txt (+1 -0) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_proposed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_proposed.snap new file mode 100644 index 0000000000..2d0c73ca0f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_proposed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&proposed_lines) +--- +• Proposed Change foo.txt (+1 -0) + 1 +hello diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index e3289220ad..e190ef4d0c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -3,6 +3,7 @@ source: tui/src/chatwidget/tests.rs assertion_line: 728 expression: terminal.backend() --- +" " "? Codex wants to run echo hello world " " " "Model wants to run a command " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 0000000000..f8aee6393d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +"? Codex wants to run echo hello world " +" " +"▌Allow command? " +"▌ Yes Always No, provide feedback " +"▌ Approve and run the command " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index b437364035..0b9b10f309 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -1,8 +1,9 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 763 +assertion_line: 794 expression: terminal.backend() --- +" " "The model wants to apply changes " " " "This will grant write access to /tmp for the remainder of this session. " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap new file mode 100644 index 0000000000..5d6d5cb455 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -0,0 +1,57 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visible_after +--- +> I’m going to scan the workspace and Cargo manifests to see build profiles and + dependencies that impact binary size. Then I’ll summarize the main causes. + +• Explored + └ List ls -la + Read Cargo.toml + +• Ran + └ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy + file-search linux-sandbox login mcp-client mcp-server mcp-types ollama + tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; + done + +• Explored + └ Read Cargo.toml + +> Here’s what’s driving size in this workspace’s binaries. + + Main Causes + + - Static linking style: Each bin (codex, codex-tui, codex-exec, + codex-mcp-server, etc.) statically links its full dependency graph, so common + code isn’t shared at runtime across executables. + - Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, + and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui, + and ollama you enable reqwest with json/stream, which still pulls a large + transitive set. + - Image/terminal stack: codex-tui includes image (with jpeg), ratatui, + crossterm, and ratatui-image, which together contribute significant code size + (decoders, pixel ops, terminal backends). + - Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and + starlark (in execpolicy) include sizeable parser/VM tables and runtimes. + - Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, + signal) across many crates inflate code size even if only subsets are used + per bin. + - Panic + backtraces: Default panic = unwind and backtrace support keep + unwinding tables and symbols that add weight. + - Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables + openssl-sys with vendored, compiling OpenSSL into the binary—this adds + multiple megabytes per executable. + + Build-Mode Notes + + - Release settings: You use lto = "fat" and codegen-units = 1 (good for size), + but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single + contributor; if you build in release with that setting, binaries can still + be large. + - Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and + assertions—outputs are much larger than cargo build --release. + + If you want, I can outline targeted trims (e.g., strip = "debuginfo", + opt-level = "z", panic abort, tighter tokio/reqwest features) and estimate + impact per binary. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index bf8bd08ba1..aeb69e846b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -1,7 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 779 expression: terminal.backend() --- "▌ Ask Codex to do anything " -" " +" ⏎ send Ctrl+J newline Ctrl+T transc" diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index 1104773679..c77477a083 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 779 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 7f84982aad..f509728658 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -1,7 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 807 expression: terminal.backend() --- +" Thinking (0s • Esc to interrupt) " "▌ Ask Codex to do anything " -" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index dbe9cbed21..c77477a083 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 807 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap new file mode 100644 index 0000000000..7708703079 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +> I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + + Investigating rendering code (0s • Esc to interrupt) + +▌Summarize recent commits + ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index 2bc5c75e05..ae86ddc85f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -2,5 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -codex -Here is the result. +> Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap new file mode 100644 index 0000000000..52b6807b4d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob +--- +🖐  '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap new file mode 100644 index 0000000000..3b33800642 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob1 +--- +⠋ Exploring + └ List ls -la diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap new file mode 100644 index 0000000000..492e8b7708 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob2 +--- +• Explored + └ List ls -la diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap new file mode 100644 index 0000000000..ddcd484b0d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob3 +--- +⠋ Exploring + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap new file mode 100644 index 0000000000..9e29785f71 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob4 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap new file mode 100644 index 0000000000..296b00f905 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob5 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap new file mode 100644 index 0000000000..55fa979123 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob6 +--- +• Explored + └ List ls -la + Read foo.txt, bar.txt diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap index 2bc5c75e05..ae86ddc85f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -2,5 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -codex -Here is the result. +> Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap index 1554e94f15..5098b824d9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -2,5 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: exec_blob --- ->_ - ✗ ⌨️ sleep 1 +• Ran sleep 1 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index c598dbe19f..72bb3c34fd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 878 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index dc7619c660..645ddb8e9f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,8 +1,9 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 851 +assertion_line: 921 expression: terminal.backend() --- +" " "? Codex wants to run echo 'hello world' " " " "Codex wants to run a command " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 758d28773a..3ca1b84142 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -23,7 +24,6 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; -use codex_login::CodexAuth; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -32,7 +32,6 @@ use pretty_assertions::assert_eq; use std::fs::File; use std::io::BufRead; use std::io::BufReader; -use std::io::Read; use std::path::PathBuf; use tokio::sync::mpsc::unbounded_channel; @@ -177,7 +176,6 @@ fn make_chatwidget_manual() -> ( last_token_usage: TokenUsage::default(), stream: StreamController::new(cfg), running_commands: HashMap::new(), - pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), @@ -185,7 +183,6 @@ fn make_chatwidget_manual() -> ( 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) @@ -196,10 +193,12 @@ fn drain_insert_history( ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { - match ev { - AppEvent::InsertHistoryLines(lines) => out.push(lines), - AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()), - _ => {} + if let AppEvent::InsertHistoryCell(cell) = ev { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + out.push(lines) } } out @@ -216,6 +215,56 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } +// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- +fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) { + // Build the full command vec and parse it using core's parser, + // then convert to protocol variants for the event payload. + let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; + let parsed_cmd: Vec = + codex_core::parse_command::parse_command(&command) + .into_iter() + .map(Into::into) + .collect(); + chat.handle_codex_event(Event { + id: call_id.to_string(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: call_id.to_string(), + command, + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd, + }), + }); +} + +fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, exit_code: i32) { + let aggregated = if stderr.is_empty() { + stdout.to_string() + } else { + format!("{stdout}{stderr}") + }; + chat.handle_codex_event(Event { + id: call_id.to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: call_id.to_string(), + stdout: stdout.to_string(), + stderr: stderr.to_string(), + aggregated_output: aggregated.clone(), + exit_code, + duration: std::time::Duration::from_millis(5), + formatted_output: aggregated, + }), + }); +} + +fn active_blob(chat: &ChatWidget) -> String { + let lines = chat + .active_exec_cell + .as_ref() + .expect("active exec cell present") + .display_lines(80); + lines_to_single_string(&lines) +} + fn open_fixture(name: &str) -> std::fs::File { // 1) Prefer fixtures within this crate { @@ -240,6 +289,20 @@ fn open_fixture(name: &str) -> std::fs::File { File::open(name).expect("open fixture file") } +#[test] +fn empty_enter_during_task_does_not_queue() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // Simulate running task so submissions would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Press Enter with an empty composer. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Ensure nothing was queued. + assert!(chat.queued_user_messages.is_empty()); +} + #[test] fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -275,45 +338,24 @@ fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command - chat.handle_codex_event(Event { - id: "call-1".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "call-1".into(), - command: vec!["bash".into(), "-lc".into(), "echo done".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Unknown { - cmd: "echo done".into(), - } - .into(), - ], - }), - }); - - // End command successfully - chat.handle_codex_event(Event { - id: "call-1".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-1".into(), - stdout: "done".into(), - stderr: String::new(), - aggregated_output: "done".into(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "done".into(), - }), - }); + begin_exec(&mut chat, "call-1", "echo done"); let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected only the completed exec cell to be inserted into history" - ); - let blob = lines_to_single_string(&cells[0]); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); + + // End command successfully + end_exec(&mut chat, "call-1", "done", "", 0); + + let cells = drain_insert_history(&mut rx); + // Exec end now finalizes and flushes the exec cell immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + // Inspect the flushed exec cell rendering. + let lines = &cells[0]; + let blob = lines_to_single_string(lines); + // New behavior: no glyph markers; ensure command is shown and no panic. assert!( - blob.contains('✓'), - "expected completed exec cell to show success marker: {blob:?}" + blob.contains("• Ran"), + "expected summary header present: {blob:?}" ); assert!( blob.contains("echo done"), @@ -326,50 +368,23 @@ fn exec_history_cell_shows_working_then_failed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command - chat.handle_codex_event(Event { - id: "call-2".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "call-2".into(), - command: vec!["bash".into(), "-lc".into(), "false".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Unknown { - cmd: "false".into(), - } - .into(), - ], - }), - }); + begin_exec(&mut chat, "call-2", "false"); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); // End command with failure - chat.handle_codex_event(Event { - id: "call-2".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-2".into(), - stdout: String::new(), - stderr: "error".into(), - aggregated_output: "error".into(), - exit_code: 2, - duration: std::time::Duration::from_millis(7), - formatted_output: "".into(), - }), - }); + end_exec(&mut chat, "call-2", "", "Bloop", 2); let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected only the completed exec cell to be inserted into history" - ); - let blob = lines_to_single_string(&cells[0]); + // Exec end with failure should also flush immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let lines = &cells[0]; + let blob = lines_to_single_string(lines); assert!( - blob.contains('✗'), - "expected failure marker present: {blob:?}" - ); - assert!( - blob.contains("false"), - "expected command text present: {blob:?}" + blob.contains("• Ran false"), + "expected command and header text present: {blob:?}" ); + assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } // Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ @@ -379,20 +394,7 @@ fn interrupt_exec_marks_failed_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin a long-running command so we have an active exec cell with a spinner. - chat.handle_codex_event(Event { - id: "call-int".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "call-int".into(), - command: vec!["bash".into(), "-lc".into(), "sleep 1".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Unknown { - cmd: "sleep 1".into(), - } - .into(), - ], - }), - }); + begin_exec(&mut chat, "call-int", "sleep 1"); // Simulate the task being aborted (as if ESC was pressed), which should // cause the active exec cell to be finalized as failed and flushed. @@ -416,75 +418,56 @@ fn interrupt_exec_marks_failed_snapshot() { #[test] fn exec_history_extends_previous_when_consecutive() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // 1) Start "ls -la" (List) + begin_exec(&mut chat, "call-ls", "ls -la"); + assert_snapshot!("exploring_step1_start_ls", active_blob(&chat)); + + // 2) Finish "ls -la" + end_exec(&mut chat, "call-ls", "", "", 0); + assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat)); + + // 3) Start "cat foo.txt" (Read) + begin_exec(&mut chat, "call-cat-foo", "cat foo.txt"); + assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat)); + + // 4) Complete "cat foo.txt" + end_exec(&mut chat, "call-cat-foo", "hello from foo", "", 0); + assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat)); + + // 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt) + begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt"); + end_exec(&mut chat, "call-sed-range", "chunk", "", 0); + assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat)); + + // 6) Start & complete "cat bar.txt" + begin_exec(&mut chat, "call-cat-bar", "cat bar.txt"); + end_exec(&mut chat, "call-cat-bar", "hello from bar", "", 0); + assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); +} + +#[test] +fn disabled_slash_command_while_task_running_snapshot() { + // Build a chat widget and simulate an active task let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.bottom_pane.set_task_running(true); - // First command - chat.handle_codex_event(Event { - id: "call-a".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "call-a".into(), - command: vec!["bash".into(), "-lc".into(), "echo one".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Unknown { - cmd: "echo one".into(), - } - .into(), - ], - }), - }); - chat.handle_codex_event(Event { - id: "call-a".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-a".into(), - stdout: "one".into(), - stderr: String::new(), - aggregated_output: "one".into(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "one".into(), - }), - }); - let first_cells = drain_insert_history(&mut rx); - assert_eq!(first_cells.len(), 1, "first exec should insert history"); + // Dispatch a command that is unavailable while a task runs (e.g., /model) + chat.dispatch_command(SlashCommand::Model); - // Second command - chat.handle_codex_event(Event { - id: "call-b".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "call-b".into(), - command: vec!["bash".into(), "-lc".into(), "echo two".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Unknown { - cmd: "echo two".into(), - } - .into(), - ], - }), - }); - chat.handle_codex_event(Event { - id: "call-b".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-b".into(), - stdout: "two".into(), - stderr: String::new(), - aggregated_output: "two".into(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "two".into(), - }), - }); - let second_cells = drain_insert_history(&mut rx); - assert_eq!(second_cells.len(), 1, "second exec should extend history"); - let first_blob = lines_to_single_string(&first_cells[0]); - let second_blob = lines_to_single_string(&second_cells[0]); - assert!(first_blob.contains('✓')); - assert!(second_blob.contains("echo two")); + // Drain history and snapshot the rendered error line(s) + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected an error message history cell to be emitted", + ); + let blob = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!(blob); } #[tokio::test(flavor = "current_thread")] -async fn binary_size_transcript_matches_ideal_fixture() { +async fn binary_size_transcript_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Set up a VT100 test terminal to capture ANSI visual output @@ -501,6 +484,7 @@ async fn binary_size_transcript_matches_ideal_fixture() { let reader = BufReader::new(file); let mut transcript = String::new(); let mut ansi: Vec = Vec::new(); + let mut has_emitted_history = false; for line in reader.lines() { let line = line.expect("read line"); @@ -526,27 +510,42 @@ async fn binary_size_transcript_matches_ideal_fixture() { let ev: Event = serde_json::from_value(upgrade_event_payload_for_tests(payload.clone())) .expect("parse"); + let ev = match ev { + Event { + msg: EventMsg::ExecCommandBegin(e), + .. + } => { + // Re-parse the command + let parsed_cmd = codex_core::parse_command::parse_command(&e.command); + Event { + id: ev.id, + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: e.call_id.clone(), + command: e.command, + cwd: e.cwd, + parsed_cmd: parsed_cmd.into_iter().map(|c| c.into()).collect(), + }), + } + } + _ => ev, + }; chat.handle_codex_event(ev); while let Ok(app_ev) = rx.try_recv() { - match app_ev { - AppEvent::InsertHistoryLines(lines) => { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + if let AppEvent::InsertHistoryCell(cell) = app_ev { + let mut lines = cell.display_lines(width); + if has_emitted_history + && !cell.is_stream_continuation() + && !lines.is_empty() + { + lines.insert(0, "".into()); } - AppEvent::InsertHistoryCell(cell) => { - let lines = cell.display_lines(); - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); - } - _ => {} + has_emitted_history = true; + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); } } } @@ -557,25 +556,21 @@ async fn binary_size_transcript_matches_ideal_fixture() { { chat.on_commit_tick(); while let Ok(app_ev) = rx.try_recv() { - match app_ev { - AppEvent::InsertHistoryLines(lines) => { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + if let AppEvent::InsertHistoryCell(cell) = app_ev { + let mut lines = cell.display_lines(width); + if has_emitted_history + && !cell.is_stream_continuation() + && !lines.is_empty() + { + lines.insert(0, "".into()); } - AppEvent::InsertHistoryCell(cell) => { - let lines = cell.display_lines(); - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); - } - _ => {} + has_emitted_history = true; + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); } } } @@ -584,19 +579,6 @@ async fn binary_size_transcript_matches_ideal_fixture() { } } - // Read the ideal fixture as-is - let mut f = open_fixture("ideal-binary-response.txt"); - let mut ideal = String::new(); - f.read_to_string(&mut ideal) - .expect("read ideal-binary-response.txt"); - // Normalize line endings for Windows vs. Unix checkouts - let ideal = ideal.replace("\r\n", "\n"); - let ideal_first_line = ideal - .lines() - .find(|l| !l.trim().is_empty()) - .unwrap_or("") - .to_string(); - // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line // and drop trailing empty lines so the shape matches the ideal fixture exactly. let mut parser = vt100::Parser::new(height, width, 0); @@ -621,24 +603,19 @@ async fn binary_size_transcript_matches_ideal_fixture() { while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } - // Compare only after the last session banner marker. Skip the transient - // 'thinking' header if present, and start from the first non-empty line - // of content that follows. + // Consider content only after the last session banner marker. Skip the transient + // 'thinking' header if present, and start from the first non-empty content line + // that follows. This keeps the snapshot stable across sessions. const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in "; let last_marker_line_idx = lines .iter() .rposition(|l| l.starts_with(MARKER_PREFIX)) .expect("marker not found in visible output"); - // Anchor to the first ideal line if present; otherwise use heuristics. + // Prefer the first assistant content line (blockquote '>' prefix) after the marker; + // fallback to the first non-empty, non-'thinking' line. let start_idx = (last_marker_line_idx + 1..lines.len()) - .find(|&idx| lines[idx].trim_start() == ideal_first_line) - .or_else(|| { - // Prefer the first assistant content line (blockquote '>' prefix) after the marker. - (last_marker_line_idx + 1..lines.len()) - .find(|&idx| lines[idx].trim_start().starts_with('>')) - }) + .find(|&idx| lines[idx].trim_start().starts_with('>')) .unwrap_or_else(|| { - // Fallback: first non-empty, non-'thinking' line (last_marker_line_idx + 1..lines.len()) .find(|&idx| { let t = lines[idx].trim_start(); @@ -653,8 +630,8 @@ async fn binary_size_transcript_matches_ideal_fixture() { compare_lines.extend(lines[(start_idx + 1)..].iter().cloned()); let visible_after = compare_lines.join("\n"); - // Normalize: drop a leading 'thinking' line if present in either side to - // avoid coupling to whether the reasoning header is rendered in history. + // Normalize: drop a leading 'thinking' line if present to avoid coupling + // to whether the reasoning header is rendered in history. fn drop_leading_thinking(s: &str) -> String { let mut it = s.lines(); let first = it.next(); @@ -666,35 +643,9 @@ async fn binary_size_transcript_matches_ideal_fixture() { } } let visible_after = drop_leading_thinking(&visible_after); - let ideal = drop_leading_thinking(&ideal); - // Normalize: strip leading Markdown blockquote markers ('>' or '> ') which - // may be present in rendered transcript lines but not in the ideal text. - fn strip_blockquotes(s: &str) -> String { - s.lines() - .map(|l| { - l.strip_prefix("> ") - .or_else(|| l.strip_prefix('>')) - .unwrap_or(l) - }) - .collect::>() - .join("\n") - } - let visible_after = strip_blockquotes(&visible_after); - let ideal = strip_blockquotes(&ideal); - - // Optionally update the fixture when env var is set - if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") { - let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - p.push("tests"); - p.push("fixtures"); - p.push("ideal-binary-response.txt"); - std::fs::write(&p, &visible_after).expect("write updated ideal fixture"); - return; - } - - // Exact equality with pretty diff on failure - assert_eq!(visible_after, ideal); + // Snapshot the normalized visible transcript following the banner. + assert_snapshot!("binary_size_ideal_response", visible_after); } // @@ -730,6 +681,33 @@ fn approval_modal_exec_snapshot() { assert_snapshot!("approval_modal_exec", terminal.backend()); } +// Snapshot test: command approval modal without a reason +// Ensures spacing looks correct when no reason text is provided. +#[test] +fn approval_modal_exec_without_reason_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest; + + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd-noreason".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + }; + chat.handle_codex_event(Event { + id: "sub-approve-noreason".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + 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 (no reason)"); + assert_snapshot!("approval_modal_exec_no_reason", terminal.backend()); +} + // Snapshot test: patch approval modal #[test] fn approval_modal_patch_snapshot() { @@ -951,11 +929,11 @@ fn apply_patch_events_emit_history_cells() { assert!(!cells.is_empty(), "expected pending patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( - blob.contains("proposed patch"), - "missing proposed patch header: {blob:?}" + blob.contains("Proposed Change"), + "missing proposed change header: {blob:?}" ); - // 2) Begin apply -> applying patch cell + // 2) Begin apply -> per-file apply block cell (no global header) let mut changes2 = HashMap::new(); changes2.insert( PathBuf::from("foo.txt"), @@ -973,11 +951,11 @@ fn apply_patch_events_emit_history_cells() { msg: EventMsg::PatchApplyBegin(begin), }); let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected applying patch cell to be sent"); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( - blob.contains("Applying patch"), - "missing applying patch header: {blob:?}" + blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), + "expected single-file header with filename (Added/Edited): {blob:?}" ); // 3) End apply success -> success cell @@ -992,11 +970,111 @@ fn apply_patch_events_emit_history_cells() { msg: EventMsg::PatchApplyEnd(end), }); let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected applied patch cell to be sent"); + assert!( + cells.is_empty(), + "no success cell should be emitted anymore" + ); +} + +#[test] +fn apply_patch_manual_approval_adjusts_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + changes: proposed_changes, + reason: None, + grant_root: None, + }), + }); + drain_insert_history(&mut rx); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( - blob.contains("Applied patch"), - "missing applied patch header: {blob:?}" + blob.contains("Change Approved foo.txt"), + "expected change approved summary: {blob:?}" + ); +} + +#[test] +fn apply_patch_manual_flow_snapshot() { + use insta::assert_snapshot; + + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + changes: proposed_changes, + reason: Some("Manual review required".into()), + grant_root: None, + }), + }); + let proposed_lines = drain_insert_history(&mut rx) + .pop() + .expect("proposed patch cell"); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + let approved_lines = drain_insert_history(&mut rx) + .pop() + .expect("approved patch cell"); + + assert_snapshot!( + "apply_patch_manual_flow_history_proposed", + lines_to_single_string(&proposed_lines) + ); + assert_snapshot!( + "apply_patch_manual_flow_history_approved", + lines_to_single_string(&approved_lines) ); } @@ -1193,7 +1271,7 @@ fn apply_patch_request_shows_diff_summary() { // Header should summarize totals assert!( - blob.contains("proposed patch to 1 file (+2 -0)"), + blob.contains("Proposed Change README.md (+2 -0)"), "missing or incorrect diff header: {blob:?}" ); @@ -1232,7 +1310,7 @@ fn plan_update_renders_history_cell() { assert!(!cells.is_empty(), "expected plan update cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( - blob.contains("Update plan"), + blob.contains("Updated Plan"), "missing plan header: {blob:?}" ); assert!(blob.contains("Explore codebase")); @@ -1254,99 +1332,11 @@ fn stream_error_is_rendered_to_history() { let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected a history cell for StreamError"); let blob = lines_to_single_string(cells.last().unwrap()); - assert!(blob.contains("⚠\u{200A} ")); + assert!(blob.contains('⚠')); assert!(blob.contains("stream error:")); assert!(blob.contains("idle timeout waiting for SSE")); } -#[test] -fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - - // Answer: no header until a newline commit - chat.handle_codex_event(Event { - id: "sub-a".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Hello".into(), - }), - }); - let mut saw_codex_pre = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryLines(lines) = ev { - let s = lines - .iter() - .flat_map(|l| l.spans.iter()) - .map(|sp| sp.content.clone()) - .collect::>() - .join(""); - if s.contains("codex") { - saw_codex_pre = true; - break; - } - } - } - assert!( - !saw_codex_pre, - "answer header should not be emitted before first newline commit" - ); - - // Newline arrives, then header is emitted - chat.handle_codex_event(Event { - id: "sub-a".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "!\n".into(), - }), - }); - chat.on_commit_tick(); - let mut saw_codex_post = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryLines(lines) = ev { - let s = lines - .iter() - .flat_map(|l| l.spans.iter()) - .map(|sp| sp.content.clone()) - .collect::>() - .join(""); - if s.contains("codex") { - saw_codex_post = true; - break; - } - } - } - assert!( - saw_codex_post, - "expected 'codex' header to be emitted after first newline commit" - ); - - // Reasoning: do NOT emit a history header; status text is updated instead - let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual(); - chat2.handle_codex_event(Event { - id: "sub-b".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "Thinking".into(), - }), - }); - let mut saw_thinking = false; - while let Ok(ev) = rx2.try_recv() { - if let AppEvent::InsertHistoryLines(lines) = ev { - let s = lines - .iter() - .flat_map(|l| l.spans.iter()) - .map(|sp| sp.content.clone()) - .collect::>() - .join(""); - if s.contains("thinking") { - saw_thinking = true; - break; - } - } - } - assert!( - !saw_thinking, - "reasoning deltas should not emit history headers" - ); -} - #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -1384,26 +1374,10 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { }); let cells = drain_insert_history(&mut rx); - let mut header_count = 0usize; - let mut combined = String::new(); - for lines in &cells { - for l in lines { - for sp in &l.spans { - let s = &sp.content; - if s == "codex" { - header_count += 1; - } - combined.push_str(s); - } - combined.push('\n'); - } - } - assert_eq!( - header_count, - 2, - "expected two 'codex' headers for two AgentMessage events in one turn; cells={:?}", - cells.len() - ); + let combined: String = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect(); assert!( combined.contains("First message"), "missing first message: {combined}" @@ -1504,3 +1478,124 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { .collect::(); assert_snapshot!(combined); } + +// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. +// This renders the final visual as seen in a terminal: history above, then a blank line, +// then the exec block, another blank line, the status line, a blank line, and the composer. +#[test] +fn chatwidget_exec_and_status_layout_vt100_snapshot() { + // Setup identical scenario + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "c1".into(), + command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![ + codex_core::parse_command::ParsedCommand::Search { + query: Some("Change Approved".into()), + path: None, + cmd: "rg \"Change Approved\"".into(), + } + .into(), + codex_core::parse_command::ParsedCommand::Read { + name: "diff_render.rs".into(), + cmd: "cat diff_render.rs".into(), + } + .into(), + ], + }), + }); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "c1".into(), + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(16000), + formatted_output: String::new(), + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Investigating rendering code**".into(), + }), + }); + chat.bottom_pane + .set_composer_text("Summarize recent commits".to_string()); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), + }); + + // Dimensions + let width: u16 = 80; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 40; + let viewport = ratatui::layout::Rect::new(0, vt_height - ui_height, width, ui_height); + + // Use TestBackend for the terminal (no real ANSI emitted by drawing), + // but capture VT100 escape stream for history insertion with a separate writer. + let backend = ratatui::backend::TestBackend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + // 1) Apply any pending history insertions by emitting ANSI to a buffer via insert_history_lines_to_writer + let mut ansi: Vec = Vec::new(); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines_to_writer(&mut term, &mut ansi, lines); + } + + // 2) Render the ChatWidget UI into an off-screen buffer using WidgetRef directly + let mut ui_buf = ratatui::buffer::Buffer::empty(viewport); + (&chat).render_ref(viewport, &mut ui_buf); + + // 3) Build VT100 visual from the captured ANSI + let mut parser = vt100::Parser::new(vt_height, width, 0); + parser.process(&ansi); + let mut vt_lines: Vec = (0..vt_height) + .map(|row| { + let mut s = String::with_capacity(width as usize); + for col in 0..width { + if let Some(cell) = parser.screen().cell(row, col) { + if let Some(ch) = cell.contents().chars().next() { + s.push(ch); + } else { + s.push(' '); + } + } else { + s.push(' '); + } + } + s.trim_end().to_string() + }) + .collect(); + + // 4) Overlay UI buffer content into the viewport region of the VT output + for rel_y in 0..viewport.height { + let y = viewport.y + rel_y; + let mut line = String::with_capacity(width as usize); + for x in 0..viewport.width { + let ch = ui_buf[(viewport.x + x, viewport.y + rel_y)] + .symbol() + .chars() + .next() + .unwrap_or(' '); + line.push(ch); + } + vt_lines[y as usize] = line.trim_end().to_string(); + } + + let visual = vt_lines.join("\n"); + insta::assert_snapshot!(visual); +} diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 5a6a8b2f2e..b15b44bddf 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -47,6 +47,7 @@ pub struct PastedImageInfo { } /// Capture image from system clipboard, encode to PNG, and return bytes + info. +#[cfg(not(target_os = "android"))] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { tracing::debug!("attempting clipboard image read"); let mut cb = arboard::Clipboard::new() @@ -70,10 +71,7 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; } - tracing::debug!( - "clipboard image encoded to PNG ({len} bytes)", - len = png.len() - ); + tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len()); Ok(( png, PastedImageInfo { @@ -84,7 +82,16 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro )) } +/// Android/Termux does not support arboard; return a clear error. +#[cfg(target_os = "android")] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + /// Convenience: write to a temp file and return its path + info. +#[cfg(not(target_os = "android"))] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { let (png, info) = paste_image_as_png()?; // Create a unique temporary file with a .png suffix to avoid collisions. @@ -101,6 +108,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag Ok((path, info)) } +#[cfg(target_os = "android")] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // Keep error consistent with paste_image_as_png. + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + /// Normalize pasted text that may represent a filesystem path. /// /// Supports: diff --git a/codex-rs/tui/src/common.rs b/codex-rs/tui/src/common.rs deleted file mode 100644 index 2c19b58706..0000000000 --- a/codex-rs/tui/src/common.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) const DEFAULT_WRAP_COLS: u16 = 80; diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 48a1665957..06afd85ca6 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -1,16 +1,17 @@ -use crossterm::terminal; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; -use crate::common::DEFAULT_WRAP_COLS; -use codex_core::protocol::FileChange; - +use crate::exec_command::relativize_to_home; use crate::history_cell::PatchEventType; +use codex_core::git_info::get_git_repo_root; +use codex_core::protocol::FileChange; const SPACES_AFTER_LINE_NUMBER: usize = 6; @@ -22,205 +23,199 @@ enum DiffLineType { } pub(crate) fn create_diff_summary( - title: &str, changes: &HashMap, event_type: PatchEventType, + cwd: &Path, + wrap_cols: usize, ) -> Vec> { - struct FileSummary { - display_path: String, - added: usize, - removed: usize, - } - - let count_from_unified = |diff: &str| -> (usize, usize) { - if let Ok(patch) = diffy::Patch::from_str(diff) { - patch - .hunks() - .iter() - .flat_map(|h| h.lines()) - .fold((0, 0), |(a, d), l| match l { - diffy::Line::Insert(_) => (a + 1, d), - diffy::Line::Delete(_) => (a, d + 1), - _ => (a, d), - }) - } else { - // Fallback: manual scan to preserve counts even for unparsable diffs - let mut adds = 0usize; - let mut dels = 0usize; - for l in diff.lines() { - if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") { - continue; - } - match l.as_bytes().first() { - Some(b'+') => adds += 1, - Some(b'-') => dels += 1, - _ => {} - } + let rows = collect_rows(changes); + let header_kind = match event_type { + PatchEventType::ApplyBegin { auto_approved } => { + if auto_approved { + HeaderKind::Edited + } else { + HeaderKind::ChangeApproved } - (adds, dels) } + PatchEventType::ApprovalRequest => HeaderKind::ProposedChange, }; - - let mut files: Vec = Vec::new(); - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => files.push(FileSummary { - display_path: path.display().to_string(), - added: content.lines().count(), - removed: 0, - }), - FileChange::Delete => files.push(FileSummary { - display_path: path.display().to_string(), - added: 0, - removed: std::fs::read_to_string(path) - .ok() - .map(|s| s.lines().count()) - .unwrap_or(0), - }), - FileChange::Update { - unified_diff, - move_path, - } => { - let (added, removed) = count_from_unified(unified_diff); - let display_path = if let Some(new_path) = move_path { - format!("{} → {}", path.display(), new_path.display()) - } else { - path.display().to_string() - }; - files.push(FileSummary { - display_path, - added, - removed, - }); - } - } - } - - let file_count = files.len(); - let total_added: usize = files.iter().map(|f| f.added).sum(); - let total_removed: usize = files.iter().map(|f| f.removed).sum(); - let noun = if file_count == 1 { "file" } else { "files" }; - - let mut out: Vec> = Vec::new(); - - // Header - let mut header_spans: Vec> = Vec::new(); - header_spans.push(RtSpan::styled( - title.to_owned(), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - )); - header_spans.push(RtSpan::raw(" to ")); - header_spans.push(RtSpan::raw(format!("{file_count} {noun} "))); - header_spans.push(RtSpan::raw("(")); - header_spans.push(RtSpan::styled( - format!("+{total_added}"), - Style::default().fg(Color::Green), - )); - header_spans.push(RtSpan::raw(" ")); - header_spans.push(RtSpan::styled( - format!("-{total_removed}"), - Style::default().fg(Color::Red), - )); - header_spans.push(RtSpan::raw(")")); - out.push(RtLine::from(header_spans)); - - // Dimmed per-file lines with prefix - for (idx, f) in files.iter().enumerate() { - let mut spans: Vec> = Vec::new(); - spans.push(RtSpan::raw(f.display_path.clone())); - // Show per-file +/- counts only when there are multiple files - if file_count > 1 { - spans.push(RtSpan::raw(" (")); - spans.push(RtSpan::styled( - format!("+{}", f.added), - Style::default().fg(Color::Green), - )); - spans.push(RtSpan::raw(" ")); - spans.push(RtSpan::styled( - format!("-{}", f.removed), - Style::default().fg(Color::Red), - )); - spans.push(RtSpan::raw(")")); - } - - let mut line = RtLine::from(spans); - let prefix = if idx == 0 { " └ " } else { " " }; - line.spans.insert(0, prefix.into()); - line.spans - .iter_mut() - .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM)); - out.push(line); - } - - let show_details = matches!( - event_type, - PatchEventType::ApplyBegin { - auto_approved: true - } | PatchEventType::ApprovalRequest - ); - - if show_details { - out.extend(render_patch_details(changes)); - } - - out + render_changes_block(rows, wrap_cols, header_kind, cwd) } -fn render_patch_details(changes: &HashMap) -> Vec> { - let mut out: Vec> = Vec::new(); - let term_cols: usize = terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(DEFAULT_WRAP_COLS.into()); +// Shared row for per-file presentation +#[derive(Clone)] +struct Row { + #[allow(dead_code)] + path: PathBuf, + move_path: Option, + added: usize, + removed: usize, + change: FileChange, +} - for (index, (path, change)) in changes.iter().enumerate() { - let is_first_file = index == 0; - // Add separator only between files (not at the very start) - if !is_first_file { - out.push(RtLine::from(vec![ - RtSpan::raw(" "), - RtSpan::styled("...", style_dim()), - ])); +fn collect_rows(changes: &HashMap) -> Vec { + let mut rows: Vec = Vec::new(); + for (path, change) in changes.iter() { + let (added, removed) = match change { + FileChange::Add { content } => (content.lines().count(), 0), + FileChange::Delete { content } => (0, content.lines().count()), + FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff), + }; + let move_path = match change { + FileChange::Update { + move_path: Some(new), + .. + } => Some(new.clone()), + _ => None, + }; + rows.push(Row { + path: path.clone(), + move_path, + added, + removed, + change: change.clone(), + }); + } + rows.sort_by_key(|r| r.path.clone()); + rows +} + +enum HeaderKind { + ProposedChange, + Edited, + ChangeApproved, +} + +fn render_changes_block( + rows: Vec, + wrap_cols: usize, + header_kind: HeaderKind, + cwd: &Path, +) -> Vec> { + let mut out: Vec> = Vec::new(); + let term_cols = wrap_cols; + + fn render_line_count_summary(added: usize, removed: usize) -> Vec> { + let mut spans = Vec::new(); + spans.push("(".into()); + spans.push(format!("+{added}").green()); + spans.push(" ".into()); + spans.push(format!("-{removed}").red()); + spans.push(")".into()); + spans + } + + let render_path = |row: &Row| -> Vec> { + let mut spans = Vec::new(); + spans.push(display_path_for(&row.path, cwd).into()); + if let Some(move_path) = &row.move_path { + spans.push(format!(" → {}", display_path_for(move_path, cwd)).into()); } - match change { + spans + }; + + // Header + let total_added: usize = rows.iter().map(|r| r.added).sum(); + let total_removed: usize = rows.iter().map(|r| r.removed).sum(); + let file_count = rows.len(); + let noun = if file_count == 1 { "file" } else { "files" }; + let mut header_spans: Vec> = vec!["• ".into()]; + match header_kind { + HeaderKind::ProposedChange => { + header_spans.push("Proposed Change".bold()); + if let [row] = &rows[..] { + header_spans.push(" ".into()); + header_spans.extend(render_path(row)); + header_spans.push(" ".into()); + header_spans.extend(render_line_count_summary(row.added, row.removed)); + } else { + header_spans.push(format!(" to {file_count} {noun} ").into()); + header_spans.extend(render_line_count_summary(total_added, total_removed)); + } + } + HeaderKind::Edited => { + if let [row] = &rows[..] { + let verb = match &row.change { + FileChange::Add { .. } => "Added", + FileChange::Delete { .. } => "Deleted", + _ => "Edited", + }; + header_spans.push(verb.bold()); + header_spans.push(" ".into()); + header_spans.extend(render_path(row)); + header_spans.push(" ".into()); + header_spans.extend(render_line_count_summary(row.added, row.removed)); + } else { + header_spans.push("Edited".bold()); + header_spans.push(format!(" {file_count} {noun} ").into()); + header_spans.extend(render_line_count_summary(total_added, total_removed)); + } + } + HeaderKind::ChangeApproved => { + header_spans.push("Change Approved".bold()); + if let [row] = &rows[..] { + header_spans.push(" ".into()); + header_spans.extend(render_path(row)); + header_spans.push(" ".into()); + header_spans.extend(render_line_count_summary(row.added, row.removed)); + } else { + header_spans.push(format!(" {file_count} {noun} ").into()); + header_spans.extend(render_line_count_summary(total_added, total_removed)); + } + } + } + out.push(RtLine::from(header_spans)); + + // For Change Approved, we only show the header summary and no per-file/diff details. + if matches!(header_kind, HeaderKind::ChangeApproved) { + return out; + } + + for (idx, r) in rows.into_iter().enumerate() { + // Insert a blank separator between file chunks (except before the first) + if idx > 0 { + out.push("".into()); + } + // File header line (skip when single-file header already shows the name) + let skip_file_header = + matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited) + && file_count == 1; + if !skip_file_header { + let mut header: Vec> = Vec::new(); + header.push(" └ ".dim()); + header.extend(render_path(&r)); + header.push(" ".into()); + header.extend(render_line_count_summary(r.added, r.removed)); + out.push(RtLine::from(header)); + } + + match r.change { FileChange::Add { content } => { for (i, raw) in content.lines().enumerate() { - let ln = i + 1; out.extend(push_wrapped_diff_line( - ln, + i + 1, DiffLineType::Insert, raw, term_cols, )); } } - FileChange::Delete => { - let original = std::fs::read_to_string(path).unwrap_or_default(); - for (i, raw) in original.lines().enumerate() { - let ln = i + 1; + FileChange::Delete { content } => { + for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( - ln, + i + 1, DiffLineType::Delete, raw, term_cols, )); } } - FileChange::Update { - unified_diff, - move_path: _, - } => { - if let Ok(patch) = diffy::Patch::from_str(unified_diff) { + FileChange::Update { unified_diff, .. } => { + if let Ok(patch) = diffy::Patch::from_str(&unified_diff) { let mut is_first_hunk = true; for h in patch.hunks() { - // Render a simple separator between non-contiguous hunks - // instead of diff-style @@ headers. if !is_first_hunk { - out.push(RtLine::from(vec![ - RtSpan::raw(" "), - RtSpan::styled("⋮", style_dim()), - ])); + out.push(RtLine::from(vec![" ".into(), "⋮".dim()])); } is_first_hunk = false; @@ -265,13 +260,41 @@ fn render_patch_details(changes: &HashMap) -> Vec String { + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { + (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, + _ => false, + }; + let chosen = if path_in_same_repo { + pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) + } else { + relativize_to_home(path).unwrap_or_else(|| path.to_path_buf()) + }; + chosen.display().to_string() +} + +fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { + if let Ok(patch) = diffy::Patch::from_str(diff) { + patch + .hunks() + .iter() + .flat_map(|h| h.lines()) + .fold((0, 0), |(a, d), l| match l { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + diffy::Line::Context(_) => (a, d), + }) + } else { + // For unparsable diffs, return 0 for both counts. + (0, 0) + } +} + fn push_wrapped_diff_line( line_number: usize, kind: DiffLineType, @@ -290,10 +313,10 @@ fn push_wrapped_diff_line( let prefix_cols = indent.len() + ln_str.len() + gap_after_ln; let mut first = true; - let (sign_opt, line_style) = match kind { - DiffLineType::Insert => (Some('+'), Some(style_add())), - DiffLineType::Delete => (Some('-'), Some(style_del())), - DiffLineType::Context => (None, None), + let (sign_char, line_style) = match kind { + DiffLineType::Insert => ('+', style_add()), + DiffLineType::Delete => ('-', style_del()), + DiffLineType::Context => (' ', style_context()), }; let mut lines: Vec> = Vec::new(); @@ -301,9 +324,7 @@ fn push_wrapped_diff_line( // Fit the content for the current terminal row: // compute how many columns are available after the prefix, then split // at a UTF-8 character boundary so this row's chunk fits exactly. - let available_content_cols = term_cols - .saturating_sub(if first { prefix_cols + 1 } else { prefix_cols }) - .max(1); + let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1); let split_at_byte_index = remaining_text .char_indices() .nth(available_content_cols) @@ -313,41 +334,22 @@ fn push_wrapped_diff_line( remaining_text = rest; if first { - let mut spans: Vec> = Vec::new(); - spans.push(RtSpan::raw(indent)); - spans.push(RtSpan::styled(ln_str.clone(), style_dim())); - spans.push(RtSpan::raw(" ".repeat(gap_after_ln))); - // Always include a sign character at the start of the displayed chunk - // ('+' for insert, '-' for delete, ' ' for context) so gutters align. - let sign_char = sign_opt.unwrap_or(' '); - let display_chunk = format!("{sign_char}{chunk}"); - let content_span = match line_style { - Some(style) => RtSpan::styled(display_chunk, style), - None => RtSpan::raw(display_chunk), - }; - spans.push(content_span); - let mut line = RtLine::from(spans); - if let Some(style) = line_style { - line.style = line.style.patch(style); - } - lines.push(line); + // Build gutter (indent + line number + spacing) as a dimmed span + let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln)); + // Content with a sign ('+'/'-'/' ') styled per diff kind + let content = format!("{sign_char}{chunk}"); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(content, line_style), + ])); first = false; } else { // Continuation lines keep a space for the sign column so content aligns - let hang_prefix = format!( - "{indent}{}{} ", - " ".repeat(ln_str.len()), - " ".repeat(gap_after_ln) - ); - let content_span = match line_style { - Some(style) => RtSpan::styled(chunk.to_string(), style), - None => RtSpan::raw(chunk.to_string()), - }; - let mut line = RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]); - if let Some(style) = line_style { - line.style = line.style.patch(style); - } - lines.push(line); + let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln)); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(chunk.to_string(), line_style), + ])); } if remaining_text.is_empty() { break; @@ -356,10 +358,14 @@ fn push_wrapped_diff_line( lines } -fn style_dim() -> Style { +fn style_gutter() -> Style { Style::default().add_modifier(Modifier::DIM) } +fn style_context() -> Style { + Style::default() +} + fn style_add() -> Style { Style::default().fg(Color::Green) } @@ -378,6 +384,12 @@ mod tests { use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; + fn diff_summary_for_tests( + changes: &HashMap, + event_type: PatchEventType, + ) -> Vec> { + create_diff_summary(changes, event_type, &PathBuf::from("/"), 80) + } fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); @@ -391,6 +403,23 @@ mod tests { assert_snapshot!(name, terminal.backend()); } + fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { + // Convert Lines to plain text rows and trim trailing spaces so it's + // easier to validate indentation visually in snapshots. + let text = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .map(|s| s.trim_end().to_string()) + .collect::>() + .join("\n"); + assert_snapshot!(name, text); + } + #[test] fn ui_snapshot_add_details() { let mut changes: HashMap = HashMap::new(); @@ -401,8 +430,7 @@ mod tests { }, ); - let lines = - create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest); snapshot_lines("add_details", lines, 80, 10); } @@ -423,8 +451,7 @@ mod tests { }, ); - let lines = - create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest); snapshot_lines("update_details_with_rename", lines, 80, 12); } @@ -435,11 +462,10 @@ mod tests { let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; // Call the wrapping function directly so we can precisely control the width - let lines = - push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into()); + let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80); // Render into a small terminal to capture the visual layout - snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8); + snapshot_lines("wrap_behavior_insert", lines, 90, 8); } #[test] @@ -458,8 +484,7 @@ mod tests { }, ); - let lines = - create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest); snapshot_lines("single_line_replacement_counts", lines, 80, 8); } @@ -480,8 +505,7 @@ mod tests { }, ); - let lines = - create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest); snapshot_lines("blank_context_line", lines, 80, 10); } @@ -503,10 +527,232 @@ mod tests { }, ); - let lines = - create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest); // Height is large enough to show both hunks and the separator snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16); } + + #[test] + fn ui_snapshot_apply_update_block() { + let mut changes: HashMap = HashMap::new(); + let original = "line one\nline two\nline three\n"; + let modified = "line one\nline two changed\nline three\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + for (name, auto_approved) in [ + ("apply_update_block", true), + ("apply_update_block_manual", false), + ] { + let lines = + diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved }); + + snapshot_lines(name, lines, 80, 12); + } + } + + #[test] + fn ui_snapshot_apply_update_with_rename_block() { + let mut changes: HashMap = HashMap::new(); + let original = "A\nB\nC\n"; + let modified = "A\nB changed\nC\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("old_name.rs"), + FileChange::Update { + unified_diff: patch, + move_path: Some(PathBuf::from("new_name.rs")), + }, + ); + + let lines = diff_summary_for_tests( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + ); + + snapshot_lines("apply_update_with_rename_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_multiple_files_block() { + // Two files: one update and one add, to exercise combined header and per-file rows + let mut changes: HashMap = HashMap::new(); + + // File a.txt: single-line replacement (one delete, one insert) + let patch_a = diffy::create_patch("one\n", "one changed\n").to_string(); + changes.insert( + PathBuf::from("a.txt"), + FileChange::Update { + unified_diff: patch_a, + move_path: None, + }, + ); + + // File b.txt: newly added with one line + changes.insert( + PathBuf::from("b.txt"), + FileChange::Add { + content: "new\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + ); + + snapshot_lines("apply_multiple_files_block", lines, 80, 14); + } + + #[test] + fn ui_snapshot_apply_add_block() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("new_file.txt"), + FileChange::Add { + content: "alpha\nbeta\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + ); + + snapshot_lines("apply_add_block", lines, 80, 10); + } + + #[test] + fn ui_snapshot_apply_delete_block() { + // Write a temporary file so the delete renderer can read original content + let tmp_path = PathBuf::from("tmp_delete_example.txt"); + std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file"); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + tmp_path.clone(), + FileChange::Delete { + content: "first\nsecond\nthird\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + ); + + // Cleanup best-effort; rendering has already read the file + let _ = std::fs::remove_file(&tmp_path); + + snapshot_lines("apply_delete_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines() { + // Create a patch with a long modified line to force wrapping + let original = "line 1\nshort\nline 3\n"; + let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("long_example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + &PathBuf::from("/"), + 72, + ); + + // Render with backend width wider than wrap width to avoid Paragraph auto-wrap. + snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines_text() { + // This mirrors the desired layout example: sign only on first inserted line, + // subsequent wrapped pieces start aligned under the line number gutter. + let original = "1\n2\n3\n4\n"; + let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("wrap_demo.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let mut lines = create_diff_summary( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + &PathBuf::from("/"), + 28, + ); + // Drop the combined header for this text-only snapshot + if !lines.is_empty() { + lines.remove(0); + } + snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); + } + + #[test] + fn ui_snapshot_apply_update_block_relativizes_path() { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let abs_old = cwd.join("abs_old.rs"); + let abs_new = cwd.join("abs_new.rs"); + + let original = "X\nY\n"; + let modified = "X changed\nY\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + abs_old.clone(), + FileChange::Update { + unified_diff: patch, + move_path: Some(abs_new.clone()), + }, + ); + + let lines = create_diff_summary( + &changes, + PatchEventType::ApplyBegin { + auto_approved: true, + }, + &cwd, + 80, + ); + + snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); + } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cc05b36fc0..f8790a0169 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -8,6 +8,8 @@ use base64::Engine; use codex_ansi_escape::ansi_escape_line; use codex_common::create_config_summary_entries; use codex_common::elapsed::format_duration; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; @@ -18,27 +20,28 @@ use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; -use codex_login::get_auth_file; -use codex_login::try_read_auth_json; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; +use itertools::Itertools; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; -use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::collections::HashMap; use std::io::Cursor; +use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use tracing::error; +use unicode_width::UnicodeWidthStr; use uuid::Uuid; #[derive(Clone, Debug)] @@ -49,6 +52,7 @@ pub(crate) struct CommandOutput { pub(crate) formatted_output: String, } +#[derive(Clone, Debug)] pub(crate) enum PatchEventType { ApprovalRequest, ApplyBegin { auto_approved: bool }, @@ -58,19 +62,110 @@ pub(crate) enum PatchEventType { /// `Vec>` representation to make it easier to display in a /// scrollable list. pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { - fn display_lines(&self) -> Vec>; + fn display_lines(&self, width: u16) -> Vec>; fn transcript_lines(&self) -> Vec> { - self.display_lines() + self.display_lines(u16::MAX) } fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines())) + Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } + + fn is_stream_continuation(&self) -> bool { + false + } +} + +#[derive(Debug)] +pub(crate) struct UserHistoryCell { + message: String, +} + +impl HistoryCell for UserHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + + // Wrap the content first, then prefix each wrapped line with the marker. + let wrap_width = width.saturating_sub(1); // account for the ▌ prefix + let wrapped = textwrap::wrap( + &self.message, + textwrap::Options::new(wrap_width as usize) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) // Match textarea wrap + .word_splitter(textwrap::WordSplitter::NoHyphenation), + ); + + for line in wrapped { + lines.push(vec!["▌".cyan().dim(), line.to_string().dim()].into()); + } + lines + } + + fn transcript_lines(&self) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push("user".cyan().bold().into()); + lines.extend(self.message.lines().map(|l| l.to_string().into())); + lines + } +} + +#[derive(Debug)] +pub(crate) struct AgentMessageCell { + lines: Vec>, + is_first_line: bool, +} + +impl AgentMessageCell { + pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines, + is_first_line, + } + } +} + +impl HistoryCell for AgentMessageCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + // We want: + // - First visual line: "> " prefix (collapse with header logic) + // - All subsequent visual lines: two-space prefix + let mut is_first_visual = true; + let wrap_width = width.saturating_sub(2); // account for prefix + for line in &self.lines { + let wrapped = + crate::insert_history::word_wrap_lines(std::slice::from_ref(line), wrap_width); + for (i, piece) in wrapped.into_iter().enumerate() { + let mut spans = Vec::with_capacity(piece.spans.len() + 1); + spans.push(if is_first_visual && i == 0 && self.is_first_line { + "> ".into() + } else { + " ".into() + }); + spans.extend(piece.spans.into_iter()); + out.push(spans.into()); + } + is_first_visual = false; + } + out + } + + fn transcript_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + if self.is_first_line { + out.push("codex".magenta().bold().into()); + } + out.extend(self.lines.clone()); + out + } + + fn is_stream_continuation(&self) -> bool { + !self.is_first_line + } } #[derive(Debug)] @@ -79,7 +174,7 @@ pub(crate) struct PlainHistoryCell { } impl HistoryCell for PlainHistoryCell { - fn display_lines(&self) -> Vec> { + fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } } @@ -90,7 +185,7 @@ pub(crate) struct TranscriptOnlyHistoryCell { } impl HistoryCell for TranscriptOnlyHistoryCell { - fn display_lines(&self) -> Vec> { + fn display_lines(&self, _width: u16) -> Vec> { Vec::new() } @@ -100,60 +195,294 @@ impl HistoryCell for TranscriptOnlyHistoryCell { } #[derive(Debug)] -pub(crate) struct ExecCell { +pub(crate) struct PatchHistoryCell { + event_type: PatchEventType, + changes: HashMap, + cwd: PathBuf, +} + +impl HistoryCell for PatchHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + create_diff_summary( + &self.changes, + self.event_type.clone(), + &self.cwd, + width as usize, + ) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ExecCall { + pub(crate) call_id: String, pub(crate) command: Vec, pub(crate) parsed: Vec, pub(crate) output: Option, start_time: Option, duration: Option, - include_header: bool, +} + +#[derive(Debug)] +pub(crate) struct ExecCell { + calls: Vec, } impl HistoryCell for ExecCell { - fn display_lines(&self) -> Vec> { - exec_command_lines( - &self.command, - &self.parsed, - self.output.as_ref(), - self.start_time, - self.include_header, - ) + fn display_lines(&self, width: u16) -> Vec> { + if self.is_exploring_cell() { + self.exploring_display_lines(width) + } else { + self.command_display_lines(width) + } } fn transcript_lines(&self) -> Vec> { - let mut lines: Vec> = vec!["".into()]; + let mut lines: Vec> = vec![]; + for call in &self.calls { + let cmd_display = strip_bash_lc_and_escape(&call.command); + for (i, part) in cmd_display.lines().enumerate() { + if i == 0 { + lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); + } else { + lines.push(vec![" ".into(), part.to_string().into()].into()); + } + } - let cmd_display = strip_bash_lc_and_escape(&self.command); - for (i, part) in cmd_display.lines().enumerate() { - if i == 0 { - lines.push(Line::from(vec!["$ ".magenta(), part.to_string().into()])); + if let Some(output) = call.output.as_ref() { + lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + let duration = call + .duration + .map(format_duration) + .unwrap_or_else(|| "unknown".to_string()); + let mut result: Line = if output.exit_code == 0 { + Line::from("✓".green().bold()) + } else { + Line::from(vec![ + "✗".red().bold(), + format!(" ({})", output.exit_code).into(), + ]) + }; + result.push_span(format!(" • {duration}").dim()); + lines.push(result); + } + lines.push("".into()); + } + lines + } +} + +impl ExecCell { + fn is_active(&self) -> bool { + self.calls.iter().any(|c| c.output.is_none()) + } + + fn exploring_display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + let active_start_time = self + .calls + .iter() + .find(|c| c.output.is_none()) + .and_then(|c| c.start_time); + lines.push(Line::from(vec![ + if self.is_active() { + // Show an animated spinner while exploring + spinner(active_start_time) } else { - lines.push(Line::from(vec![" ".into(), part.to_string().into()])); + "•".bold() + }, + " ".into(), + if self.is_active() { + "Exploring".bold() + } else { + "Explored".bold() + }, + ])); + let mut calls = self.calls.clone(); + let mut first = true; + while !calls.is_empty() { + let mut call = calls.remove(0); + if call + .parsed + .iter() + .all(|c| matches!(c, ParsedCommand::Read { .. })) + { + while let Some(next) = calls.first() { + if next + .parsed + .iter() + .all(|c| matches!(c, ParsedCommand::Read { .. })) + { + call.parsed.extend(next.parsed.clone()); + calls.remove(0); + } else { + break; + } + } + } + let call_lines: Vec<(&str, Vec>)> = if call + .parsed + .iter() + .all(|c| matches!(c, ParsedCommand::Read { .. })) + { + let names: Vec = call + .parsed + .iter() + .map(|c| match c { + ParsedCommand::Read { name, .. } => name.clone(), + _ => unreachable!(), + }) + .unique() + .collect(); + vec![( + "Read", + itertools::Itertools::intersperse( + names.into_iter().map(|n| n.into()), + ", ".dim(), + ) + .collect(), + )] + } else { + let mut lines = Vec::new(); + for p in call.parsed { + match p { + ParsedCommand::Read { name, .. } => { + lines.push(("Read", vec![name.into()])); + } + ParsedCommand::ListFiles { cmd, path } => { + lines.push(("List", vec![path.unwrap_or(cmd).into()])); + } + ParsedCommand::Search { cmd, query, path } => { + lines.push(( + "Search", + match (query, path) { + (Some(q), Some(p)) => { + vec![q.into(), " in ".dim(), p.into()] + } + (Some(q), None) => vec![q.into()], + _ => vec![cmd.into()], + }, + )); + } + ParsedCommand::Unknown { cmd } => { + lines.push(("Run", vec![cmd.into()])); + } + } + } + lines + }; + for (title, line) in call_lines { + let prefix_len = 4 + title.len() + 1; // " └ " + title + " " + let wrapped = crate::insert_history::word_wrap_lines( + &[line.into()], + width.saturating_sub(prefix_len as u16), + ); + let mut first_sub = true; + for mut line in wrapped { + let mut spans = Vec::with_capacity(line.spans.len() + 1); + spans.push(if first { + first = false; + " └ ".dim() + } else { + " ".into() + }); + if first_sub { + first_sub = false; + spans.push(title.cyan()); + spans.push(" ".into()); + } else { + spans.push(" ".repeat(title.width() + 1).into()); + } + spans.extend(line.spans.into_iter()); + line.spans = spans; + lines.push(line); + } } } + lines + } - // Command output: include full stdout and stderr (no truncation) - if let Some(output) = self.output.as_ref() { - lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + fn command_display_lines(&self, width: u16) -> Vec> { + use textwrap::Options as TwOptions; + use textwrap::WordSplitter; + + let mut lines: Vec> = Vec::new(); + let [call] = &self.calls.as_slice() else { + panic!("Expected exactly one call in a command display cell"); + }; + let success = call.output.as_ref().map(|o| o.exit_code == 0); + let bullet = match success { + Some(true) => "•".green().bold(), + Some(false) => "•".red().bold(), + None => spinner(call.start_time), + }; + let title = if self.is_active() { "Running" } else { "Ran" }; + let cmd_display = strip_bash_lc_and_escape(&call.command); + + // If the command fits on the same line as the header at the current width, + // show a single compact line: "• Ran ". Use the width of + // "• Running " (including trailing space) as the reserved prefix width. + // If the command contains newlines, always use the multi-line variant. + let reserved = "• Running ".width(); + let mut branch_consumed = false; + + if !cmd_display.contains('\n') + && cmd_display.width() < (width as usize).saturating_sub(reserved) + { + lines.push(Line::from(vec![ + bullet, + " ".into(), + title.bold(), + " ".into(), + cmd_display.clone().into(), + ])); + } else { + branch_consumed = true; + lines.push(vec![bullet, " ".into(), title.bold()].into()); + + // Wrap the command line. + for (i, line) in cmd_display.lines().enumerate() { + let wrapped = textwrap::wrap( + line, + TwOptions::new(width as usize) + .initial_indent(" ") + .subsequent_indent(" ") + .word_splitter(WordSplitter::NoHyphenation), + ); + lines.extend(wrapped.into_iter().enumerate().map(|(j, l)| { + if i == 0 && j == 0 { + vec![" └ ".dim(), l[4..].to_string().into()].into() + } else { + l.to_string().into() + } + })); + } } - - if let Some(output) = self.output.as_ref() { - let duration = self - .duration - .map(format_duration) - .unwrap_or_else(|| "unknown".to_string()); - let mut result = if output.exit_code == 0 { - Line::from("✓".green().bold()) - } else { - Line::from(vec![ - "✗".red().bold(), - format!(" ({})", output.exit_code).into(), - ]) - }; - - result.push_span(format!(" • {duration}").dim()); - lines.push(result); + if let Some(output) = call.output.as_ref() + && output.exit_code != 0 + { + let out = output_lines(Some(output), false, false, false) + .into_iter() + .join("\n"); + if !out.trim().is_empty() { + // Wrap the output. + for (i, line) in out.lines().enumerate() { + let wrapped = textwrap::wrap( + line, + TwOptions::new(width as usize - 4) + .word_splitter(WordSplitter::NoHyphenation), + ); + lines.extend(wrapped.into_iter().map(|l| { + Line::from(vec![ + if i == 0 && !branch_consumed { + " └ ".dim() + } else { + " ".dim() + }, + l.to_string().dim(), + ]) + })); + } + } } - lines } } @@ -169,7 +498,16 @@ impl WidgetRef for &ExecCell { width: area.width, height: area.height, }; - Paragraph::new(Text::from(self.display_lines())) + let lines = self.display_lines(area.width); + let max_rows = area.height as usize; + let rendered = if lines.len() > max_rows { + // Keep the last `max_rows` lines in original order + lines[lines.len() - max_rows..].to_vec() + } else { + lines + }; + + Paragraph::new(Text::from(rendered)) .wrap(Wrap { trim: false }) .render(content_area, buf); } @@ -177,22 +515,86 @@ impl WidgetRef for &ExecCell { impl ExecCell { /// Convert an active exec cell into a failed, completed exec cell. - /// Replaces the spinner with a red ✗ and sets a zero/elapsed duration. + /// Any call without output is marked as failed with a red ✗. pub(crate) fn into_failed(mut self) -> ExecCell { - let elapsed = self - .start_time - .map(|st| st.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - self.start_time = None; - self.duration = Some(elapsed); - self.output = Some(CommandOutput { - exit_code: 1, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }); + for call in self.calls.iter_mut() { + if call.output.is_none() { + let elapsed = call + .start_time + .map(|st| st.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + call.start_time = None; + call.duration = Some(elapsed); + call.output = Some(CommandOutput { + exit_code: 1, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }); + } + } self } + + pub(crate) fn new(call: ExecCall) -> Self { + ExecCell { calls: vec![call] } + } + + fn is_exploring_call(call: &ExecCall) -> bool { + !call.parsed.is_empty() + && call.parsed.iter().all(|p| { + matches!( + p, + ParsedCommand::Read { .. } + | ParsedCommand::ListFiles { .. } + | ParsedCommand::Search { .. } + ) + }) + } + + fn is_exploring_cell(&self) -> bool { + self.calls.iter().all(Self::is_exploring_call) + } + + pub(crate) fn with_added_call( + &self, + call_id: String, + command: Vec, + parsed: Vec, + ) -> Option { + let call = ExecCall { + call_id, + command, + parsed, + output: None, + start_time: Some(Instant::now()), + duration: None, + }; + if self.is_exploring_cell() && Self::is_exploring_call(&call) { + Some(Self { + calls: [self.calls.clone(), vec![call]].concat(), + }) + } else { + None + } + } + + pub(crate) fn complete_call( + &mut self, + call_id: &str, + output: CommandOutput, + duration: Duration, + ) { + if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { + call.output = Some(output); + call.duration = Some(duration); + call.start_time = None; + } + } + + pub(crate) fn should_flush(&self) -> bool { + !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) + } } #[derive(Debug)] @@ -200,11 +602,8 @@ struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self) -> Vec> { - vec![ - Line::from(""), - Line::from("tool result (image output omitted)"), - ] + fn display_lines(&self, _width: u16) -> Vec> { + vec!["tool result (image output omitted)".into()] } } @@ -237,11 +636,6 @@ fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } -/// Convenience function over `padded_emoji()`. -fn padded_emoji_with(emoji: &str, text: impl AsRef) -> String { - format!("{}{}", padded_emoji(emoji), text.as_ref()) -} - pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, @@ -262,272 +656,94 @@ pub(crate) fn new_session_info( Some(_) => "~".to_string(), None => config.cwd.display().to_string(), }; + // Discover AGENTS.md files to decide whether to suggest `/init`. + let has_agents_md = discover_project_doc_paths(config) + .map(|v| !v.is_empty()) + .unwrap_or(false); - let lines: Vec> = vec![ - Line::from(Span::from("")), - Line::from(vec![ - Span::raw(">_ ").dim(), - Span::styled( - "You are using OpenAI Codex in", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" {cwd_str}")).dim(), - ]), - Line::from("".dim()), - Line::from(" To get started, describe a task or try one of these commands:".dim()), - Line::from("".dim()), - Line::from(vec![ - Span::styled( - " /init", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White), - ), - Span::styled( - format!(" - {}", SlashCommand::Init.description()), - Style::default().dim(), - ), - ]), - Line::from(vec![ - Span::styled( - " /status", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White), - ), - Span::styled( - format!(" - {}", SlashCommand::Status.description()), - Style::default().dim(), - ), - ]), - Line::from(vec![ - Span::styled( - " /approvals", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White), - ), - Span::styled( - format!(" - {}", SlashCommand::Approvals.description()), - Style::default().dim(), - ), - ]), - Line::from(vec![ - Span::styled( - " /model", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White), - ), - Span::styled( - format!(" - {}", SlashCommand::Model.description()), - Style::default().dim(), - ), - ]), - ]; + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + ">_ ".dim(), + "You are using OpenAI Codex in".bold(), + format!(" {cwd_str}").dim(), + ])); + lines.push(Line::from("".dim())); + lines.push(Line::from( + " To get started, describe a task or try one of these commands:".dim(), + )); + lines.push(Line::from("".dim())); + if !has_agents_md { + lines.push(Line::from(vec![ + " /init".bold(), + format!(" - {}", SlashCommand::Init.description()).dim(), + ])); + } + lines.push(Line::from(vec![ + " /status".bold(), + format!(" - {}", SlashCommand::Status.description()).dim(), + ])); + lines.push(Line::from(vec![ + " /approvals".bold(), + format!(" - {}", SlashCommand::Approvals.description()).dim(), + ])); + lines.push(Line::from(vec![ + " /model".bold(), + format!(" - {}", SlashCommand::Model.description()).dim(), + ])); PlainHistoryCell { lines } } else if config.model == model { PlainHistoryCell { lines: Vec::new() } } else { let lines = vec![ - Line::from(""), - Line::from("model changed:".magenta().bold()), - Line::from(format!("requested: {}", config.model)), - Line::from(format!("used: {model}")), + "model changed:".magenta().bold().into(), + format!("requested: {}", config.model).into(), + format!("used: {model}").into(), ]; PlainHistoryCell { lines } } } -pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("")); - lines.push(Line::from("user".cyan().bold())); - lines.extend(message.lines().map(|l| Line::from(l.to_string()))); +pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { + UserHistoryCell { message } +} +pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHistoryCell { PlainHistoryCell { lines } } pub(crate) fn new_active_exec_command( + call_id: String, command: Vec, parsed: Vec, - include_header: bool, ) -> ExecCell { - ExecCell { + ExecCell::new(ExecCall { + call_id, command, parsed, output: None, start_time: Some(Instant::now()), duration: None, - include_header, - } + }) } -pub(crate) fn new_completed_exec_command( - command: Vec, - parsed: Vec, - output: CommandOutput, - include_header: bool, - duration: Duration, -) -> ExecCell { - ExecCell { - command, - parsed, - output: Some(output), - start_time: None, - duration: Some(duration), - include_header, - } -} - -fn exec_command_lines( - command: &[String], - parsed: &[ParsedCommand], - output: Option<&CommandOutput>, - start_time: Option, - include_header: bool, -) -> Vec> { - match parsed.is_empty() { - true => new_exec_command_generic(command, output, start_time, include_header), - false => new_parsed_command(command, parsed, output, start_time, include_header), - } -} -fn new_parsed_command( - _command: &[String], - parsed_commands: &[ParsedCommand], - output: Option<&CommandOutput>, - start_time: Option, - include_header: bool, -) -> Vec> { - let mut lines: Vec = Vec::new(); - // Leading spacer and header line above command list - if include_header { - lines.push(Line::from("")); - lines.push(Line::from(">_".magenta())); - } - - // Determine the leading status marker: spinner while running, ✓ on success, ✗ on failure. - let status_marker: Span<'static> = match output { - None => { - // Animated braille spinner – choose frame based on elapsed time. - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - Span::raw(format!("{ch}")) - } - Some(o) if o.exit_code == 0 => Span::styled("✓", Style::default().fg(Color::Green)), - Some(_) => Span::styled("✗", Style::default().fg(Color::Red)), - }; - - for parsed in parsed_commands.iter() { - let text = match parsed { - ParsedCommand::Read { name, .. } => padded_emoji_with("📖", name), - ParsedCommand::ListFiles { cmd, path } => match path { - Some(p) => padded_emoji_with("📂", p), - None => padded_emoji_with("📂", cmd), - }, - ParsedCommand::Search { query, path, cmd } => match (query, path) { - (Some(q), Some(p)) => padded_emoji_with("🔎", format!("{q} in {p}")), - (Some(q), None) => padded_emoji_with("🔎", q), - (None, Some(p)) => padded_emoji_with("🔎", p), - (None, None) => padded_emoji_with("🔎", cmd), - }, - ParsedCommand::Format { .. } => padded_emoji_with("✨", "Formatting"), - ParsedCommand::Test { cmd } => padded_emoji_with("🧪", cmd), - ParsedCommand::Lint { cmd, .. } => padded_emoji_with("🧹", cmd), - ParsedCommand::Unknown { cmd } => padded_emoji_with("⌨️", cmd), - ParsedCommand::Noop { cmd } => padded_emoji_with("🔄", cmd), - }; - // Prefix: two spaces, marker, space. Continuations align under the text block. - for (j, line_text) in text.lines().enumerate() { - if j == 0 { - lines.push(Line::from(vec![ - " ".into(), - status_marker.clone(), - " ".into(), - line_text.to_string().light_blue(), - ])); - } else { - lines.push(Line::from(vec![ - " ".into(), - line_text.to_string().light_blue(), - ])); - } - } - } - - lines.extend(output_lines(output, true, false)); - - lines -} - -fn new_exec_command_generic( - command: &[String], - output: Option<&CommandOutput>, - start_time: Option, - include_header: bool, -) -> Vec> { - let mut lines: Vec> = Vec::new(); - // Leading spacer and header line above command list - if include_header { - lines.push(Line::from("")); - lines.push(Line::from(">_".magenta())); - } - let command_escaped = strip_bash_lc_and_escape(command); - - // Determine marker: spinner while running, ✓/✗ when completed - let status_marker: Span<'static> = match output { - None => { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - Span::raw(format!("{ch}")) - } - Some(o) if o.exit_code == 0 => Span::styled("✓", Style::default().fg(Color::Green)), - Some(_) => Span::styled("✗", Style::default().fg(Color::Red)), - }; - - for (i, line) in command_escaped.lines().enumerate() { - if i == 0 { - lines.push(Line::from(vec![ - Span::raw(" "), - status_marker.clone(), - Span::raw(" "), - Span::raw(line.to_string()), - ])); - } else { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(line.to_string()), - ])); - } - } - - lines.extend(output_lines(output, false, true)); - - lines +fn spinner(start_time: Option) -> Span<'static> { + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let idx = start_time + .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) + .unwrap_or(0); + let ch = FRAMES[idx]; + ch.to_string().into() } pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); - let lines: Vec = vec![ - Line::from(""), - title_line, - format_mcp_invocation(invocation.clone()), - ]; + let lines: Vec = vec![title_line, format_mcp_invocation(invocation.clone())]; PlainHistoryCell { lines } } pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell { - let lines: Vec> = vec![ - Line::from(""), - Line::from(vec![padded_emoji("🌐").into(), query.into()]), - ]; + let lines: Vec> = vec![Line::from(vec![padded_emoji("🌐").into(), query.into()])]; PlainHistoryCell { lines } } @@ -637,18 +853,10 @@ pub(crate) fn new_completed_mcp_tool_call( } } Err(e) => { - lines.push(Line::from(vec![ - Span::styled( - "Error: ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - Span::raw(e), - ])); + lines.push(vec!["Error: ".red().bold(), e.into()].into()); } }; - // Leading blank separator at the start of this cell - lines.insert(0, Line::from("")); Box::new(PlainHistoryCell { lines }) } @@ -658,8 +866,7 @@ pub(crate) fn new_status_output( session_id: &Option, ) -> PlainHistoryCell { let mut lines: Vec> = Vec::new(); - lines.push(Line::from("")); - lines.push(Line::from("/status".magenta())); + lines.push("/status".magenta().into()); let config_entries = create_config_summary_entries(config); let lookup = |k: &str| -> String { @@ -671,10 +878,7 @@ pub(crate) fn new_status_output( }; // 📂 Workspace - lines.push(Line::from(vec![ - padded_emoji("📂").into(), - "Workspace".bold(), - ])); + lines.push(vec![padded_emoji("📂").into(), "Workspace".bold()].into()); // 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() => { @@ -684,22 +888,16 @@ pub(crate) fn new_status_output( Some(_) => "~".to_string(), None => config.cwd.display().to_string(), }; - lines.push(Line::from(vec![" • Path: ".into(), cwd_str.into()])); + lines.push(vec![" • Path: ".into(), cwd_str.into()].into()); // Approval mode (as-is) - lines.push(Line::from(vec![ - " • Approval Mode: ".into(), - lookup("approval").into(), - ])); + lines.push(vec![" • Approval Mode: ".into(), lookup("approval").into()].into()); // Sandbox (simplified name only) let sandbox_name = match &config.sandbox_policy { SandboxPolicy::DangerFullAccess => "danger-full-access", SandboxPolicy::ReadOnly => "read-only", SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", }; - lines.push(Line::from(vec![ - " • Sandbox: ".into(), - sandbox_name.into(), - ])); + lines.push(vec![" • Sandbox: ".into(), sandbox_name.into()].into()); // AGENTS.md files discovered via core's project_doc logic let agents_list = { @@ -742,84 +940,62 @@ pub(crate) fn new_status_output( } }; if agents_list.is_empty() { - lines.push(Line::from(" • AGENTS files: (none)")); + lines.push(" • AGENTS files: (none)".into()); } else { - lines.push(Line::from(vec![ - " • AGENTS files: ".into(), - agents_list.join(", ").into(), - ])); + lines.push(vec![" • AGENTS files: ".into(), agents_list.join(", ").into()].into()); } + lines.push("".into()); // 👤 Account (only if ChatGPT tokens exist), shown under the first block let auth_file = get_auth_file(&config.codex_home); if let Ok(auth) = try_read_auth_json(&auth_file) && let Some(tokens) = auth.tokens.clone() { - lines.push(Line::from(vec![ - padded_emoji("👤").into(), - "Account".bold(), - ])); - lines.push(Line::from(" • Signed in with ChatGPT")); + lines.push(vec![padded_emoji("👤").into(), "Account".bold()].into()); + lines.push(" • Signed in with ChatGPT".into()); let info = tokens.id_token; if let Some(email) = &info.email { - lines.push(Line::from(vec![" • Login: ".into(), email.clone().into()])); + lines.push(vec![" • Login: ".into(), email.clone().into()].into()); } match auth.openai_api_key.as_deref() { Some(key) if !key.is_empty() => { - lines.push(Line::from( - " • Using API key. Run codex login to use ChatGPT plan", - )); + lines.push(" • Using API key. Run codex login to use ChatGPT plan".into()); } _ => { let plan_text = info .get_chatgpt_plan_type() .map(|s| title_case(&s)) .unwrap_or_else(|| "Unknown".to_string()); - lines.push(Line::from(vec![" • Plan: ".into(), plan_text.into()])); + lines.push(vec![" • Plan: ".into(), plan_text.into()].into()); } } - lines.push(Line::from("")); + lines.push("".into()); } // 🧠 Model - lines.push(Line::from(vec![padded_emoji("🧠").into(), "Model".bold()])); - lines.push(Line::from(vec![ - " • Name: ".into(), - config.model.clone().into(), - ])); + lines.push(vec![padded_emoji("🧠").into(), "Model".bold()].into()); + lines.push(vec![" • Name: ".into(), config.model.clone().into()].into()); let provider_disp = pretty_provider_name(&config.model_provider_id); - lines.push(Line::from(vec![ - " • Provider: ".into(), - provider_disp.into(), - ])); + lines.push(vec![" • Provider: ".into(), provider_disp.into()].into()); // Only show Reasoning fields if present in config summary let reff = lookup("reasoning effort"); if !reff.is_empty() { - lines.push(Line::from(vec![ - " • Reasoning Effort: ".into(), - title_case(&reff).into(), - ])); + lines.push(vec![" • Reasoning Effort: ".into(), title_case(&reff).into()].into()); } let rsum = lookup("reasoning summaries"); if !rsum.is_empty() { - lines.push(Line::from(vec![ - " • Reasoning Summaries: ".into(), - title_case(&rsum).into(), - ])); + lines.push(vec![" • Reasoning Summaries: ".into(), title_case(&rsum).into()].into()); } - lines.push(Line::from("")); + lines.push("".into()); // 📊 Token Usage - lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()])); + lines.push(vec!["📊 ".into(), "Token Usage".bold()].into()); if let Some(session_id) = session_id { - lines.push(Line::from(vec![ - " • Session ID: ".into(), - session_id.to_string().into(), - ])); + lines.push(vec![" • Session ID: ".into(), session_id.to_string().into()].into()); } // Input: [+ cached] let mut input_line_spans: Vec> = vec![ @@ -849,18 +1025,14 @@ pub(crate) fn new_status_output( /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ - Line::from(""), - Line::from("/mcp".magenta()), - Line::from(""), - Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), - Line::from(""), - Line::from(" • No MCP servers configured.".italic()), + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), + " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - Span::styled( - "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), @@ -875,15 +1047,15 @@ pub(crate) fn new_mcp_tools_output( tools: std::collections::HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ - Line::from("/mcp".magenta()), - Line::from(""), - Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), - Line::from(""), + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), ]; if tools.is_empty() { - lines.push(Line::from(" • No MCP tools available.".italic())); - lines.push(Line::from("")); + lines.push(" • No MCP tools available.".italic().into()); + lines.push("".into()); return PlainHistoryCell { lines }; } @@ -896,18 +1068,12 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); - lines.push(Line::from(vec![ - " • Server: ".into(), - server.clone().into(), - ])); + lines.push(vec![" • Server: ".into(), server.clone().into()].into()); if !cfg.command.is_empty() { let cmd_display = format!("{} {}", cfg.command, cfg.args.join(" ")); - lines.push(Line::from(vec![ - " • Command: ".into(), - cmd_display.into(), - ])); + lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); } if let Some(env) = cfg.env.as_ref() @@ -915,19 +1081,13 @@ pub(crate) fn new_mcp_tools_output( { let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); env_pairs.sort(); - lines.push(Line::from(vec![ - " • Env: ".into(), - env_pairs.join(" ").into(), - ])); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); } if names.is_empty() { - lines.push(Line::from(" • Tools: (none)")); + lines.push(" • Tools: (none)".into()); } else { - lines.push(Line::from(vec![ - " • Tools: ".into(), - names.join(", ").into(), - ])); + lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } lines.push(Line::from("")); } @@ -939,124 +1099,104 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter // in terminals like Ghostty. - let lines: Vec> = vec![ - "".into(), - vec![padded_emoji("🖐").red().bold(), " ".into(), message.into()].into(), - ]; + let lines: Vec> = + vec![vec![padded_emoji("🖐").red().bold(), " ".into(), message.into()].into()]; PlainHistoryCell { lines } } pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { - let lines: Vec> = vec![ - vec![ - padded_emoji("⚠").magenta().bold(), - " ".into(), - message.dim(), - ] - .into(), - "".into(), - ]; + let lines: Vec> = vec![vec![padded_emoji("⚠️").into(), message.dim()].into()]; PlainHistoryCell { lines } } /// Render a user‑friendly plan update styled like a checkbox todo list. -pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell { +pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { let UpdatePlanArgs { explanation, plan } = update; + PlanUpdateCell { explanation, plan } +} - let mut lines: Vec> = Vec::new(); - // Leading blank for separation - lines.push(Line::from("")); - // Header with progress summary - let total = plan.len(); - let completed = plan - .iter() - .filter(|p| matches!(p.status, StepStatus::Completed)) - .count(); +#[derive(Debug)] +pub(crate) struct PlanUpdateCell { + explanation: Option, + plan: Vec, +} - let width: usize = 10; - let filled = if total > 0 { - (completed * width + total / 2) / total - } else { - 0 - }; - let empty = width.saturating_sub(filled); +impl HistoryCell for PlanUpdateCell { + fn display_lines(&self, width: u16) -> Vec> { + let render_note = |text: &str| -> Vec> { + let wrap_width = width.saturating_sub(4).max(1) as usize; + textwrap::wrap(text, wrap_width) + .into_iter() + .map(|s| s.to_string().dim().italic().into()) + .collect() + }; - let mut header: Vec = Vec::new(); - header.push(Span::raw("📋")); - header.push(Span::styled( - " Update plan", - Style::default().add_modifier(Modifier::BOLD).magenta(), - )); - header.push(Span::raw(" [")); - if filled > 0 { - header.push(Span::styled( - "█".repeat(filled), - Style::default().fg(Color::Green), - )); - } - if empty > 0 { - header.push(Span::styled( - "░".repeat(empty), - Style::default().add_modifier(Modifier::DIM), - )); - } - header.push(Span::raw("] ")); - header.push(Span::raw(format!("{completed}/{total}"))); - lines.push(Line::from(header)); - - // Optional explanation/note from the model - if let Some(expl) = explanation.and_then(|s| { - let t = s.trim().to_string(); - if t.is_empty() { None } else { Some(t) } - }) { - lines.push(Line::from("note".dim().italic())); - for l in expl.lines() { - lines.push(Line::from(l.to_string()).dim()); - } - } - - // Steps styled as checkbox items - if plan.is_empty() { - lines.push(Line::from("(no steps provided)".dim().italic())); - } else { - for (idx, PlanItemArg { step, status }) in plan.into_iter().enumerate() { - let (box_span, text_span) = match status { - StepStatus::Completed => ( - Span::styled("✔", Style::default().fg(Color::Green)), - Span::styled( - step, - Style::default().add_modifier(Modifier::CROSSED_OUT | Modifier::DIM), - ), - ), - StepStatus::InProgress => ( - Span::raw("□"), - Span::styled( - step, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - ), - StepStatus::Pending => ( - Span::raw("□"), - Span::styled(step, Style::default().add_modifier(Modifier::DIM)), - ), + let render_step = |status: &StepStatus, text: &str| -> Vec> { + let (box_str, step_style) = match status { + StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()), + StepStatus::InProgress => ("□ ", Style::default().cyan().bold()), + StepStatus::Pending => ("□ ", Style::default().dim()), }; - let prefix = if idx == 0 { - Span::raw(" └ ") - } else { - Span::raw(" ") - }; - lines.push(Line::from(vec![ - prefix, - box_span, - Span::raw(" "), - text_span, - ])); - } - } + let wrap_width = (width as usize) + .saturating_sub(4) + .saturating_sub(box_str.width()) + .max(1); + let parts = textwrap::wrap(text, wrap_width); + let step_text = parts + .into_iter() + .map(|s| s.to_string().set_style(step_style).into()) + .collect(); + prefix_lines(step_text, &box_str.into(), &" ".into()) + }; - PlainHistoryCell { lines } + fn prefix_lines( + lines: Vec>, + initial_prefix: &Span<'static>, + subsequent_prefix: &Span<'static>, + ) -> Vec> { + lines + .into_iter() + .enumerate() + .map(|(i, l)| { + Line::from( + [ + vec![if i == 0 { + initial_prefix.clone() + } else { + subsequent_prefix.clone() + }], + l.spans, + ] + .concat(), + ) + }) + .collect() + } + + let mut lines: Vec> = vec![]; + lines.push(vec!["• ".into(), "Updated Plan".bold()].into()); + + let mut indented_lines = vec![]; + let note = self + .explanation + .as_ref() + .map(|s| s.trim()) + .filter(|t| !t.is_empty()); + if let Some(expl) = note { + indented_lines.extend(render_note(expl)); + }; + + if self.plan.is_empty() { + indented_lines.push(Line::from("(no steps provided)".dim().italic())); + } else { + for PlanItemArg { step, status } in self.plan.iter() { + indented_lines.extend(render_step(status, step)); + } + } + lines.extend(prefix_lines(indented_lines, &" └ ".into(), &" ".into())); + + lines + } } /// Create a new `PendingPatch` cell that lists the file‑level summary of @@ -1065,28 +1205,13 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell { pub(crate) fn new_patch_event( event_type: PatchEventType, changes: HashMap, -) -> PlainHistoryCell { - let title = match &event_type { - PatchEventType::ApprovalRequest => "proposed patch", - PatchEventType::ApplyBegin { - auto_approved: true, - } => "✏️ Applying patch", - PatchEventType::ApplyBegin { - auto_approved: false, - } => { - let lines: Vec> = vec![ - Line::from(""), - Line::from("✏️ Applying patch".magenta().bold()), - ]; - return PlainHistoryCell { lines }; - } - }; - - let mut lines: Vec> = create_diff_summary(title, &changes, event_type); - // Add leading blank separator for the cell - lines.insert(0, Line::from("")); - - PlainHistoryCell { lines } + cwd: &Path, +) -> PatchHistoryCell { + PatchHistoryCell { + event_type, + changes, + cwd: cwd.to_path_buf(), + } } pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { @@ -1105,59 +1230,10 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { }), true, true, + true, )); } - // Leading blank separator - lines.insert(0, Line::from("")); - PlainHistoryCell { lines } -} - -pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell { - let mut lines: Vec> = Vec::new(); - - // Success title - lines.push(Line::from("✓ Applied patch".magenta().bold())); - - if !stdout.trim().is_empty() { - let mut iter = stdout.lines(); - for (i, raw) in iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() { - let prefix = if i == 0 { " └ " } else { " " }; - - // First line is the header; dim it entirely. - if i == 0 { - let s = format!("{prefix}{raw}"); - lines.push(ansi_escape_line(&s).dim()); - continue; - } - - // Subsequent lines should look like: "M path/to/file". - // Colorize the status letter like `git status` (e.g., M red). - let status = raw.chars().next(); - let rest = raw.get(1..).unwrap_or(""); - - let status_span = match status { - Some('M') => "M".red(), - Some('A') => "A".green(), - Some('D') => "D".red(), - Some(other) => other.to_string().into(), - None => "".into(), - }; - - lines.push(Line::from(vec![ - prefix.into(), - status_span, - ansi_escape_line(rest).to_string().into(), - ])); - } - let remaining = iter.count(); - if remaining > 0 { - lines.push(Line::from("")); - lines.push(Line::from(format!("... +{remaining} lines")).dim()); - } - } - // Leading blank separator - lines.insert(0, Line::from("")); PlainHistoryCell { lines } } @@ -1166,16 +1242,54 @@ pub(crate) fn new_reasoning_block( config: &Config, ) -> TranscriptOnlyHistoryCell { let mut lines: Vec> = Vec::new(); - lines.push(Line::from("")); lines.push(Line::from("thinking".magenta().italic())); append_markdown(&full_reasoning_buffer, &mut lines, config); TranscriptOnlyHistoryCell { lines } } +pub(crate) fn new_reasoning_summary_block( + full_reasoning_buffer: String, + config: &Config, +) -> Vec> { + if config.use_experimental_reasoning_summary { + // Experimental format is following: + // ** header ** + // + // reasoning summary + // + // So we need to strip header from reasoning summary + if let Some(open) = full_reasoning_buffer.find("**") { + let after_open = &full_reasoning_buffer[(open + 2)..]; + if let Some(close) = after_open.find("**") { + let after_close_idx = open + 2 + close + 2; + let header_buffer = full_reasoning_buffer[..after_close_idx].to_string(); + let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); + + let mut header_lines: Vec> = Vec::new(); + header_lines.push(Line::from("Thinking".magenta().italic())); + append_markdown(&header_buffer, &mut header_lines, config); + + let mut summary_lines: Vec> = Vec::new(); + summary_lines.push(Line::from("Thinking".magenta().bold())); + append_markdown(&summary_buffer, &mut summary_lines, config); + + return vec![ + Box::new(TranscriptOnlyHistoryCell { + lines: header_lines, + }), + Box::new(AgentMessageCell::new(summary_lines, true)), + ]; + } + } + } + vec![Box::new(new_reasoning_block(full_reasoning_buffer, config))] +} + fn output_lines( output: Option<&CommandOutput>, only_err: bool, include_angle_pipe: bool, + include_prefix: bool, ) -> Vec> { let CommandOutput { exit_code, @@ -1198,7 +1312,9 @@ fn output_lines( let head_end = total.min(limit); for (i, raw) in lines[..head_end].iter().enumerate() { let mut line = ansi_escape_line(raw); - let prefix = if i == 0 && include_angle_pipe { + let prefix = if !include_prefix { + "" + } else if i == 0 && include_angle_pipe { " └ " } else { " " @@ -1214,7 +1330,7 @@ fn output_lines( let show_ellipsis = total > 2 * limit; if show_ellipsis { let omitted = total - 2 * limit; - out.push(Line::from(format!("… +{omitted} lines"))); + out.push(format!("… +{omitted} lines").into()); } let tail_start = if show_ellipsis { @@ -1224,7 +1340,9 @@ fn output_lines( }; for raw in lines[tail_start..].iter() { let mut line = ansi_escape_line(raw); - line.spans.insert(0, " ".into()); + if include_prefix { + line.spans.insert(0, " ".into()); + } line.spans.iter_mut().for_each(|span| { span.style = span.style.add_modifier(Modifier::DIM); }); @@ -1245,14 +1363,14 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { .unwrap_or_default(); let invocation_spans = vec![ - Span::styled(invocation.server.clone(), Style::default().fg(Color::Cyan)), - Span::raw("."), - Span::styled(invocation.tool.clone(), Style::default().fg(Color::Cyan)), - Span::raw("("), - Span::styled(args_str, Style::default().add_modifier(Modifier::DIM)), - Span::raw(")"), + invocation.server.clone().cyan(), + ".".into(), + invocation.tool.clone().cyan(), + "(".into(), + args_str.dim(), + ")".into(), ]; - Line::from(invocation_spans) + invocation_spans.into() } #[cfg(test)] @@ -1260,18 +1378,552 @@ mod tests { use super::*; #[test] - fn parsed_command_with_newlines_starts_each_line_at_origin() { - let parsed = vec![ParsedCommand::Unknown { - cmd: "printf 'foo\nbar'".to_string(), - }]; - let lines = exec_command_lines(&[], &parsed, None, None, true); - assert!(lines.len() >= 4); - // Leading spacer then header line - assert!(lines[0].spans.is_empty() || lines[0].spans[0].content.is_empty()); - assert_eq!(lines[1].spans[0].content, ">_"); - // First rendered command line starts with two-space + marker. - assert_eq!(lines[2].spans[0].content, " "); - // Continuation lines align under the text block. - assert_eq!(lines[3].spans[0].content, " "); + fn coalesces_sequential_reads_within_one_call() { + // Build one exec cell with a Search followed by two Reads + let call_id = "c1".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), "echo".into()], + parsed: vec![ + ParsedCommand::Search { + query: Some("shimmer_spans".into()), + path: None, + cmd: "rg shimmer_spans".into(), + }, + ParsedCommand::Read { + name: "shimmer.rs".into(), + cmd: "cat shimmer.rs".into(), + }, + ParsedCommand::Read { + name: "status_indicator_widget.rs".into(), + cmd: "cat status_indicator_widget.rs".into(), + }, + ], + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + // Mark call complete so markers are ✓ + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + + let lines = cell.display_lines(80); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn coalesces_reads_across_multiple_calls() { + let mut cell = ExecCell::new(ExecCall { + call_id: "c1".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo".into()], + parsed: vec![ParsedCommand::Search { + query: Some("shimmer_spans".into()), + path: None, + cmd: "rg shimmer_spans".into(), + }], + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + // Call 1: Search only + cell.complete_call( + "c1", + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + // Call 2: Read A + cell = cell + .with_added_call( + "c2".into(), + vec!["bash".into(), "-lc".into(), "echo".into()], + vec![ParsedCommand::Read { + name: "shimmer.rs".into(), + cmd: "cat shimmer.rs".into(), + }], + ) + .unwrap(); + cell.complete_call( + "c2", + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + // Call 3: Read B + cell = cell + .with_added_call( + "c3".into(), + vec!["bash".into(), "-lc".into(), "echo".into()], + vec![ParsedCommand::Read { + name: "status_indicator_widget.rs".into(), + cmd: "cat status_indicator_widget.rs".into(), + }], + ) + .unwrap(); + cell.complete_call( + "c3", + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + + let lines = cell.display_lines(80); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn coalesced_reads_dedupe_names() { + let mut cell = ExecCell::new(ExecCall { + call_id: "c1".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo".into()], + parsed: vec![ + ParsedCommand::Read { + name: "auth.rs".into(), + cmd: "cat auth.rs".into(), + }, + ParsedCommand::Read { + name: "auth.rs".into(), + cmd: "cat auth.rs".into(), + }, + ParsedCommand::Read { + name: "shimmer.rs".into(), + cmd: "cat shimmer.rs".into(), + }, + ], + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + cell.complete_call( + "c1", + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + let lines = cell.display_lines(80); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() { + // Create a completed exec cell with a multiline command + let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string(); + let call_id = "c1".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), cmd], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + // Mark call complete so it renders as "Ran" + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + + // Small width to force wrapping on both lines + let width: u16 = 28; + let lines = cell.display_lines(width); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn single_line_command_compact_when_fits() { + let call_id = "c1".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["echo".into(), "ok".into()], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + // Wide enough that it fits inline + let lines = cell.display_lines(80); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn single_line_command_wraps_with_four_space_continuation() { + let call_id = "c1".to_string(); + let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), long], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + let lines = cell.display_lines(24); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn multiline_command_without_wrap_uses_branch_then_eight_spaces() { + let call_id = "c1".to_string(); + let cmd = "echo one\necho two".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), cmd], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + let lines = cell.display_lines(80); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn multiline_command_both_lines_wrap_with_correct_prefixes() { + let call_id = "c1".to_string(); + let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap" + .to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), cmd], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + let lines = cell.display_lines(28); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn stderr_tail_more_than_five_lines_snapshot() { + // Build an exec cell with a non-zero exit and 10 lines on stderr to exercise + // the head/tail rendering and gutter prefixes. + let call_id = "c_err".to_string(); + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + let stderr: String = (1..=10) + .map(|n| n.to_string()) + .collect::>() + .join("\n"); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 1, + stdout: String::new(), + stderr, + formatted_output: String::new(), + }, + Duration::from_millis(1), + ); + + let rendered = cell + .display_lines(80) + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn ran_cell_multiline_with_stderr_snapshot() { + // Build an exec cell that completes (so it renders as "Ran") with a + // command long enough that it must render on its own line under the + // header, and include a couple of stderr lines to verify the output + // block prefixes and wrapping. + let call_id = "c_wrap_err".to_string(); + let long_cmd = + "echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width"; + let mut cell = ExecCell::new(ExecCall { + call_id: call_id.clone(), + command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()], + parsed: Vec::new(), + output: None, + start_time: Some(Instant::now()), + duration: None, + }); + + let stderr = "error: first line on stderr\nerror: second line on stderr".to_string(); + cell.complete_call( + &call_id, + CommandOutput { + exit_code: 1, + stdout: String::new(), + stderr, + formatted_output: String::new(), + }, + Duration::from_millis(5), + ); + + // Narrow width to force the command to render under the header line. + let width: u16 = 28; + let rendered = cell + .display_lines(width) + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + #[test] + fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { + let msg = "one two three four five six seven"; + let cell = UserHistoryCell { + message: msg.to_string(), + }; + + // Small width to force wrapping more clearly. Effective wrap width is width-1 due to the ▌ prefix. + let width: u16 = 12; + let lines = cell.display_lines(width); + + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered); + } + + #[test] + fn plan_update_with_note_and_wrapping_snapshot() { + // Long explanation forces wrapping; include long step text to verify step wrapping and alignment. + let update = UpdatePlanArgs { + explanation: Some( + "I’ll update Grafana call error handling by adding retries and clearer messages when the backend is unreachable." + .to_string(), + ), + plan: vec![ + PlanItemArg { + step: "Investigate existing error paths and logging around HTTP timeouts".into(), + status: StepStatus::Completed, + }, + PlanItemArg { + step: "Harden Grafana client error handling with retry/backoff and user‑friendly messages".into(), + status: StepStatus::InProgress, + }, + PlanItemArg { + step: "Add tests for transient failure scenarios and surfacing to the UI".into(), + status: StepStatus::Pending, + }, + ], + }; + + let cell = new_plan_update(update); + // Narrow width to force wrapping for both the note and steps + let lines = cell.display_lines(32); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn plan_update_without_note_snapshot() { + let update = UpdatePlanArgs { + explanation: None, + plan: vec![ + PlanItemArg { + step: "Define error taxonomy".into(), + status: StepStatus::InProgress, + }, + PlanItemArg { + step: "Implement mapping to user messages".into(), + status: StepStatus::Pending, + }, + ], + }; + + let cell = new_plan_update(update); + let lines = cell.display_lines(40); + let rendered = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered); } } diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index b638870935..1ecbd3448b 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -21,7 +21,8 @@ use ratatui::text::Span; use textwrap::Options as TwOptions; use textwrap::WordSplitter; -/// Insert `lines` above the viewport. +/// Insert `lines` above the viewport using the terminal's backend writer +/// (avoids direct stdout references). pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) { let mut out = std::io::stdout(); insert_history_lines_to_writer(terminal, &mut out, lines); @@ -262,7 +263,10 @@ where } /// Word-aware wrapping for a list of `Line`s preserving styles. -pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec> { +pub(crate) fn word_wrap_lines<'a, I>(lines: I, width: u16) -> Vec> +where + I: IntoIterator>, +{ let mut out = Vec::new(); let w = width.max(1) as usize; for line in lines { @@ -415,7 +419,7 @@ mod tests { #[test] fn line_height_counts_double_width_emoji() { - let line = Line::from("😀😀😀"); // each emoji ~ width 2 + let line = "😀😀😀".into(); // each emoji ~ width 2 assert_eq!(word_wrap_line(&line, 4).len(), 2); assert_eq!(word_wrap_line(&line, 2).len(), 3); assert_eq!(word_wrap_line(&line, 6).len(), 1); @@ -424,7 +428,7 @@ mod tests { #[test] fn word_wrap_does_not_split_words_simple_english() { let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; - let line = Line::from(sample); + let line = sample.into(); // Force small width to exercise wrapping at spaces. let wrapped = word_wrap_lines(&[line], 40); let joined: String = wrapped diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 544aec272a..5ce17f7aa6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,7 +4,9 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -12,11 +14,9 @@ use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_login::AuthManager; -use codex_login::AuthMode; -use codex_login::CodexAuth; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use std::fs::OpenOptions; use std::path::PathBuf; use tracing::error; @@ -34,7 +34,6 @@ mod chatwidget; mod citation_regex; mod cli; mod clipboard_paste; -mod common; pub mod custom_terminal; mod diff_render; mod exec_command; @@ -257,7 +256,6 @@ async fn run_ratatui_app( if let Some(latest_version) = updates::get_upgrade_version(&config) { use ratatui::style::Stylize as _; use ratatui::text::Line; - use ratatui::text::Span; let current_version = env!("CARGO_PKG_VERSION"); let exe = std::env::current_exe()?; @@ -266,35 +264,35 @@ async fn run_ratatui_app( let mut lines: Vec> = Vec::new(); lines.push(Line::from(vec![ "✨⬆️ Update available!".bold().cyan(), - Span::raw(" "), - Span::raw(format!("{current_version} -> {latest_version}.")), + " ".into(), + format!("{current_version} -> {latest_version}.").into(), ])); if managed_by_npm { let npm_cmd = "npm install -g @openai/codex@latest"; lines.push(Line::from(vec![ - Span::raw("Run "), + "Run ".into(), npm_cmd.cyan(), - Span::raw(" to update."), + " to update.".into(), ])); } else if cfg!(target_os = "macos") && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) { let brew_cmd = "brew upgrade codex"; lines.push(Line::from(vec![ - Span::raw("Run "), + "Run ".into(), brew_cmd.cyan(), - Span::raw(" to update."), + " to update.".into(), ])); } else { lines.push(Line::from(vec![ - Span::raw("See "), + "See ".into(), "https://github.com/openai/codex/releases/latest".cyan(), - Span::raw(" for the latest releases and installation options."), + " for the latest releases and installation options.".into(), ])); } - lines.push(Line::from("")); + lines.push("".into()); tui.insert_history_lines(lines); } @@ -303,7 +301,11 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; - let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + config.preferred_auth_method, + config.responses_originator_header.clone(), + ); let login_status = get_login_status(&config); let should_show_onboarding = should_show_onboarding(login_status, &config, should_show_trust_screen); @@ -359,7 +361,11 @@ fn get_login_status(config: &Config) -> LoginStatus { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home( + &codex_home, + config.preferred_auth_method, + &config.responses_originator_header, + ) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index fcb9e774a0..6aff205a35 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -2,7 +2,6 @@ use crate::citation_regex::CITATION_REGEX; use codex_core::config::Config; use codex_core::config_types::UriBasedFileOpener; use ratatui::text::Line; -use ratatui::text::Span; use std::borrow::Cow; use std::path::Path; @@ -44,7 +43,7 @@ fn append_markdown_with_opener_and_cwd( } else { line }; - let owned_line: Line<'static> = Line::from(Span::raw(line.to_string())); + let owned_line: Line<'static> = line.to_string().into(); lines.push(owned_line); } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 347289d2fc..c7217ac1af 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] -use codex_login::AuthManager; -use codex_login::CLIENT_ID; +use codex_core::AuthManager; +use codex_core::auth::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -15,12 +15,11 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use std::sync::RwLock; use crate::LoginStatus; @@ -120,20 +119,14 @@ impl AuthModeWidget { fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ - Span::raw("> "), - Span::styled( - "Sign in with ChatGPT to use Codex as part of your paid plan", - Style::default().add_modifier(Modifier::BOLD), - ), + "> ".into(), + "Sign in with ChatGPT to use Codex as part of your paid plan".bold(), ]), Line::from(vec![ - Span::raw(" "), - Span::styled( - "or connect an API key for usage-based billing", - Style::default().add_modifier(Modifier::BOLD), - ), + " ".into(), + "or connect an API key for usage-based billing".bold(), ]), - Line::from(""), + "".into(), ]; // If the user is already authenticated but the method differs from their @@ -150,8 +143,8 @@ impl AuthModeWidget { to_label(current), to_label(self.preferred_auth_method) ); - lines.push(Line::from(msg).style(Style::default())); - lines.push(Line::from("")); + lines.push(msg.into()); + lines.push("".into()); } let create_mode_item = |idx: usize, @@ -168,7 +161,7 @@ impl AuthModeWidget { text.to_string().cyan(), ]) } else { - Line::from(format!(" {}. {text}", idx + 1)) + format!(" {}. {text}", idx + 1).into() }; let line2 = if is_selected { @@ -207,19 +200,15 @@ impl AuthModeWidget { api_key_label, "Pay for what you use", )); - lines.push(Line::from("")); + lines.push("".into()); lines.push( // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. - Line::from(" Press Enter to continue") - .style(Style::default().add_modifier(Modifier::DIM)), + " Press Enter to continue".dim().into(), ); if let Some(err) = &self.error { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - err.as_str(), - Style::default().fg(Color::Red), - ))); + lines.push("".into()); + lines.push(err.as_str().red().into()); } Paragraph::new(lines) @@ -228,28 +217,23 @@ impl AuthModeWidget { } fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { - let mut spans = vec![Span::from("> ")]; + let mut spans = vec!["> ".into()]; // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); - let mut lines = vec![Line::from(spans), Line::from("")]; + let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { - lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); - lines.push(Line::from(vec![ - Span::raw(" "), - state.auth_url.as_str().cyan().underlined(), - ])); - lines.push(Line::from("")); + lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); + lines.push(vec![" ".into(), state.auth_url.as_str().cyan().underlined()].into()); + lines.push("".into()); } - lines.push( - Line::from(" Press Esc to cancel").style(Style::default().add_modifier(Modifier::DIM)), - ); + lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); @@ -257,35 +241,28 @@ impl AuthModeWidget { fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ - Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green), - Line::from(""), - Line::from("> Before you start:"), - Line::from(""), - Line::from(" Decide how much autonomy you want to grant Codex"), + "✓ Signed in with your ChatGPT account".fg(Color::Green).into(), + "".into(), + "> Before you start:".into(), + "".into(), + " Decide how much autonomy you want to grant Codex".into(), Line::from(vec![ - Span::raw(" For more details see the "), - Span::styled( - "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + " For more details see the ".into(), + "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(), ]) - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Codex can make mistakes"), - Line::from(" Review the code it writes and commands it runs") - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Powered by your ChatGPT account"), + .dim(), + "".into(), + " Codex can make mistakes".into(), + " Review the code it writes and commands it runs".dim().into(), + "".into(), + " Powered by your ChatGPT account".into(), Line::from(vec![ - Span::raw(" Uses your plan's rate limits and "), - Span::styled( - "\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + " Uses your plan's rate limits and ".into(), + "\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(), ]) - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Press Enter to continue").fg(Color::Cyan), + .dim(), + "".into(), + " Press Enter to continue".fg(Color::Cyan).into(), ]; Paragraph::new(lines) @@ -294,7 +271,11 @@ impl AuthModeWidget { } fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) { - let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green)]; + let lines = vec![ + "✓ Signed in with your ChatGPT account" + .fg(Color::Green) + .into(), + ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) @@ -302,7 +283,7 @@ impl AuthModeWidget { } fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) { - let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)]; + let lines = vec!["✓ Using OPENAI_API_KEY".fg(Color::Green).into()]; Paragraph::new(lines) .wrap(Wrap { trim: false }) @@ -311,13 +292,11 @@ impl AuthModeWidget { fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ - Line::from( - " To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment", - ) - .style(Style::default().fg(Color::Cyan)), - Line::from(""), - Line::from(" Press Enter to return") - .style(Style::default().add_modifier(Modifier::DIM)), + " To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment" + .fg(Color::Cyan) + .into(), + "".into(), + " Press Enter to return".dim().into(), ]; Paragraph::new(lines) diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index f0009bb9fb..5afd393c02 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,5 @@ -use codex_core::util::is_inside_git_repo; -use codex_login::AuthManager; +use codex_core::AuthManager; +use codex_core::git_info::get_git_repo_root; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -9,7 +9,7 @@ use ratatui::prelude::Widget; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; @@ -88,7 +88,7 @@ impl OnboardingScreen { auth_manager, })) } - let is_git_repo = is_inside_git_repo(&cwd); + let is_git_repo = get_git_repo_root(&cwd).is_some(); let highlighted = if is_git_repo { TrustDirectorySelection::Trust } else { diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index e775a829df..ce3f2a3c6a 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -9,10 +9,8 @@ use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Color; use ratatui::style::Modifier; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; @@ -41,30 +39,25 @@ impl WidgetRef for &TrustDirectoryWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ - Span::raw("> "), - Span::styled( - "You are running Codex in ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(self.cwd.to_string_lossy().to_string()), + "> ".into(), + "You are running Codex in ".bold(), + self.cwd.to_string_lossy().to_string().into(), ]), - Line::from(""), + "".into(), ]; if self.is_git_repo { - lines.push(Line::from( - " Since this folder is version controlled, you may wish to allow Codex", - )); - lines.push(Line::from( - " to work in this folder without asking for approval.", - )); + lines.push( + " Since this folder is version controlled, you may wish to allow Codex".into(), + ); + lines.push(" to work in this folder without asking for approval.".into()); } else { - lines.push(Line::from( - " Since this folder is not version controlled, we recommend requiring", - )); - lines.push(Line::from(" approval of all edits and commands.")); + lines.push( + " Since this folder is not version controlled, we recommend requiring".into(), + ); + lines.push(" approval of all edits and commands.".into()); } - lines.push(Line::from("")); + lines.push("".into()); let create_option = |idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> { @@ -99,10 +92,10 @@ impl WidgetRef for &TrustDirectoryWidget { "Require approval of edits and commands", )); } - lines.push(Line::from("")); + lines.push("".into()); if let Some(error) = &self.error { lines.push(Line::from(format!(" {error}")).fg(Color::Red)); - lines.push(Line::from("")); + lines.push("".into()); } // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index bcdc5c9aae..5e6906bf95 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,10 +1,8 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; -use ratatui::style::Modifier; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::WidgetRef; use crate::onboarding::onboarding_screen::StepStateProvider; @@ -18,11 +16,8 @@ pub(crate) struct WelcomeWidget { impl WidgetRef for &WelcomeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let line = Line::from(vec![ - Span::raw(">_ "), - Span::styled( - "Welcome to Codex, OpenAI's command-line coding agent", - Style::default().add_modifier(Modifier::BOLD), - ), + ">_ ".into(), + "Welcome to Codex, OpenAI's command-line coding agent".bold(), ]); line.render(area, buf); } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ed29b8fc11..c730b2fd3b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -143,7 +143,7 @@ impl PagerView { .dim() .render_ref(area, buf); let header = format!("/ {}", self.title); - Span::from(header).dim().render_ref(area, buf); + header.dim().render_ref(area, buf); } // Removed unused render_content_page (replaced by render_content_page_prepared) @@ -545,7 +545,7 @@ mod tests { fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title let mut overlay = StaticOverlay::with_title( - vec![Line::from("one"), Line::from("two"), Line::from("three")], + vec!["one".into(), "two".into(), "three".into()], "S T A T I C".to_string(), ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); @@ -557,7 +557,7 @@ mod tests { #[test] fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() { let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output."; - let mut pv = PagerView::new(vec![Line::from(long), Line::from(long)], "T".to_string(), 0); + let mut pv = PagerView::new(vec![long.into(), long.into()], "T".to_string(), 0); // Build cache at width 24 pv.ensure_wrapped(24); @@ -586,13 +586,13 @@ mod tests { #[test] fn pager_wrap_cache_invalidates_on_append() { let long = "Another long line for wrapping behavior verification."; - let mut pv = PagerView::new(vec![Line::from(long)], "T".to_string(), 0); + let mut pv = PagerView::new(vec![long.into()], "T".to_string(), 0); pv.ensure_wrapped(28); let (w1, _) = pv.cached(); let len1 = w1.len(); // Append new lines should cause ensure_wrapped to rebuild due to len change - pv.lines.extend([Line::from(long), Line::from(long)]); + pv.lines.extend([long.into(), long.into()]); pv.ensure_wrapped(28); let (w2, _) = pv.cached(); assert!( diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 4937be8b5f..3118800b71 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -140,16 +140,6 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } - // Internal UI events; still log for fidelity, but avoid heavy payloads. - AppEvent::InsertHistoryLines(lines) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "insert_history", - "lines": lines.len(), - }); - LOGGER.write_json_line(value); - } AppEvent::InsertHistoryCell(cell) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap index 143113fb68..1ff570945b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap @@ -1,9 +1,9 @@ --- source: tui/src/diff_render.rs +assertion_line: 765 expression: terminal.backend() --- -"proposed patch to 1 file (+2 -0) " -" └ README.md " +"• Proposed Change README.md (+2 -0) " " 1 +first line " " 2 +second line " " " @@ -12,3 +12,4 @@ expression: terminal.backend() " " " " " " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap new file mode 100644 index 0000000000..254f51b644 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Added new_file.txt (+2 -0) " +" 1 +alpha " +" 2 +beta " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap new file mode 100644 index 0000000000..989200b228 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Deleted tmp_delete_example.txt (+0 -3) " +" 1 -first " +" 2 -second " +" 3 -third " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap new file mode 100644 index 0000000000..62e1f58e1b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Edited 2 files (+2 -1) " +" └ a.txt (+1 -1) " +" 1 -one " +" 1 +one changed " +" " +" └ b.txt (+1 -0) " +" 1 +new " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap new file mode 100644 index 0000000000..305120d388 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/diff_render.rs +assertion_line: 748 +expression: terminal.backend() +--- +"• Edited example.txt (+1 -1) " +" 1 line one " +" 2 -line two " +" 2 +line two changed " +" 3 line three " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap new file mode 100644 index 0000000000..12c10ca460 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Change Approved example.txt (+1 -1) " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap new file mode 100644 index 0000000000..825e50ebc9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/diff_render.rs +assertion_line: 748 +expression: terminal.backend() +--- +"• Edited abs_old.rs → abs_new.rs (+1 -1) " +" 1 -X " +" 1 +X changed " +" 2 Y " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap new file mode 100644 index 0000000000..d571b00638 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/diff_render.rs +assertion_line: 748 +expression: terminal.backend() +--- +"• Edited long_example.txt (+1 -1) " +" 1 line 1 " +" 2 -short " +" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro " +" ss_multiple_terminal_columns_and_continue_even_further_beyond " +" _eighty_columns_to_force_multiple_wraps " +" 3 line 3 " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap new file mode 100644 index 0000000000..ea495c10cb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/diff_render.rs +expression: text +--- + 1 1 + 2 -2 + 2 +added long line w + hich wraps and_if + _there_is_a_long_ + token_it_will_be_ + broken + 3 3 + 4 -4 + 4 +4 context line wh + ich also wraps ac + ross diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap new file mode 100644 index 0000000000..89c2ddf141 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/diff_render.rs +assertion_line: 748 +expression: terminal.backend() +--- +"• Edited old_name.rs → new_name.rs (+1 -1) " +" 1 A " +" 2 -B " +" 2 +B changed " +" 3 C " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap index b38704f9ad..a662041e8d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap @@ -1,9 +1,9 @@ --- source: tui/src/diff_render.rs +assertion_line: 765 expression: terminal.backend() --- -"proposed patch to 1 file (+1 -1) " -" └ example.txt " +"• Proposed Change example.txt (+1 -1) " " 1 " " 2 -Y " " 2 +Y changed " @@ -12,3 +12,4 @@ expression: terminal.backend() " " " " " " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap index b92dcbd646..d4a7bd8bf3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap @@ -1,12 +1,13 @@ --- source: tui/src/diff_render.rs +assertion_line: 765 expression: terminal.backend() --- -"proposed patch to 1 file (+1 -1) " -" └ README.md " +"• Proposed Change README.md (+1 -1) " " 1 -# Codex CLI (Rust Implementation) " " 1 +# Codex CLI (Rust Implementation) banana " " " " " " " " " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap index 459791e024..6e5c34ba24 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap @@ -1,9 +1,9 @@ --- source: tui/src/diff_render.rs +assertion_line: 765 expression: terminal.backend() --- -"proposed patch to 1 file (+1 -1) " -" └ src/lib.rs → src/lib_new.rs " +"• Proposed Change src/lib.rs → src/lib_new.rs (+1 -1) " " 1 line one " " 2 -line two " " 2 +line two changed " @@ -14,3 +14,4 @@ expression: terminal.backend() " " " " " " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap index 0f4bfd5aed..bc7743886d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap @@ -1,9 +1,9 @@ --- source: tui/src/diff_render.rs +assertion_line: 765 expression: terminal.backend() --- -"proposed patch to 1 file (+2 -2) " -" └ example.txt " +"• Proposed Change example.txt (+2 -2) " " 1 line 1 " " 2 -line 2 " " 2 +line two changed " @@ -18,3 +18,4 @@ expression: terminal.backend() " 9 +line nine changed " " 10 line 10 " " " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap new file mode 100644 index 0000000000..2044d41a1a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Read auth.rs, shimmer.rs diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap new file mode 100644 index 0000000000..fcfa31eede --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Search shimmer_spans + Read shimmer.rs, status_indicator_widget.rs diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap new file mode 100644 index 0000000000..533a489ee6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Search shimmer_spans + Read shimmer.rs + Read status_indicator_widget.rs diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap new file mode 100644 index 0000000000..99d6579fe4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran + └ first_token_is_long_ + enough_to_wrap + second_token_is_also + _long_enough_to_wrap diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap new file mode 100644 index 0000000000..7dc9d5722f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran + └ echo one + echo two diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap new file mode 100644 index 0000000000..4e383a5aa2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran + └ set -o pipefail + cargo test + --all-features + --quiet diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap new file mode 100644 index 0000000000..27649a11d6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Updated Plan + └ I’ll update Grafana call + error handling by adding + retries and clearer + messages when the backend is + unreachable. + ✔ Investigate existing error + paths and logging around + HTTP timeouts + □ Harden Grafana client + error handling with retry/ + backoff and user‑friendly + messages + □ Add tests for transient + failure scenarios and + surfacing to the UI diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap new file mode 100644 index 0000000000..e3ac8844f8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Updated Plan + └ □ Define error taxonomy + □ Implement mapping to user messages diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap new file mode 100644 index 0000000000..4a0ec93876 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran + └ echo + this_is_a_very_long_ + single_token_that_wi + ll_wrap_across_the_a + vailable_width + error: first line on + stderr + error: second line on + stderr diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap new file mode 100644 index 0000000000..b4a79261dc --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran echo ok diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap new file mode 100644 index 0000000000..548d50cea2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran + └ a_very_long_toke + n_without_spaces + _to_force_wrappi + ng diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap new file mode 100644 index 0000000000..ccba8c22ca --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran seq 1 10 1>&2 && false + └ 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap new file mode 100644 index 0000000000..8b8cc38a03 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +▌one two +▌three four +▌five six +▌seven diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 9c6db452ba..4edea186dd 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1,3 +1,5 @@ +use crate::history_cell; +use crate::history_cell::HistoryCell; use codex_core::config::Config; use ratatui::text::Line; @@ -6,7 +8,7 @@ use super::StreamState; /// Sink for history insertions and animation control. pub(crate) trait HistorySink { - fn insert_history(&self, lines: Vec>); + fn insert_history_cell(&self, cell: Box); fn start_commit_animation(&self); fn stop_commit_animation(&self); } @@ -15,9 +17,9 @@ pub(crate) trait HistorySink { pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEventSender); impl HistorySink for AppEventHistorySink { - fn insert_history(&self, lines: Vec>) { + fn insert_history_cell(&self, cell: Box) { self.0 - .send(crate::app_event::AppEvent::InsertHistoryLines(lines)) + .send(crate::app_event::AppEvent::InsertHistoryCell(cell)) } fn start_commit_animation(&self) { self.0 @@ -66,10 +68,6 @@ impl StreamController { // leave header state unchanged; caller decides when to reset } - fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool { - self.header.maybe_emit(out_lines) - } - /// Begin an answer stream. Does not emit header yet; it is emitted on first commit. pub(crate) fn begin(&mut self, _sink: &impl HistorySink) { // Starting a new stream cancels any pending finish-from-previous-stream animation. @@ -124,10 +122,11 @@ impl StreamController { out_lines.extend(step.history); } if !out_lines.is_empty() { - let mut lines_with_header: Lines = Vec::new(); - self.emit_header_if_needed(&mut lines_with_header); - lines_with_header.extend(out_lines); - sink.insert_history(lines_with_header); + // Insert as a HistoryCell so display drops the header while transcript keeps it. + sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new( + out_lines, + self.header.maybe_emit_header(), + ))); } // Cleanup @@ -159,11 +158,10 @@ impl StreamController { } let step = { self.state.step() }; if !step.history.is_empty() { - let mut lines: Lines = Vec::new(); - self.emit_header_if_needed(&mut lines); - let mut out = lines; - out.extend(step.history); - sink.insert_history(out); + sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new( + step.history, + self.header.maybe_emit_header(), + ))); } let is_idle = self.state.is_idle(); @@ -244,8 +242,9 @@ mod tests { } } impl HistorySink for TestSink { - fn insert_history(&self, lines: Vec>) { - self.lines.borrow_mut().push(lines); + fn insert_history_cell(&self, cell: Box) { + // For tests, store the transcript representation of the cell. + self.lines.borrow_mut().push(cell.transcript_lines()); } fn start_commit_animation(&self) {} fn stop_commit_animation(&self) {} diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index eaaeeff4be..3fc81360ba 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -62,20 +62,13 @@ impl HeaderEmitter { self.emitted_this_turn = false; } - pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec>) -> bool { + pub(crate) fn maybe_emit_header(&mut self) -> bool { if !self.emitted_in_stream && !self.emitted_this_turn { - // Add a leading blank line before the header for visual spacing - out_lines.push(ratatui::text::Line::from("")); - out_lines.push(render_header_line()); self.emitted_in_stream = true; self.emitted_this_turn = true; - return true; + true + } else { + false } - false } } - -fn render_header_line() -> ratatui::text::Line<'static> { - use ratatui::style::Stylize; - ratatui::text::Line::from("codex".magenta().bold()) -} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 47a858c55a..df957fee87 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -252,10 +252,10 @@ impl Tui { if next_deadline.is_none_or(|cur| at < cur) { next_deadline = Some(at); } - if at <= Instant::now() { - next_deadline = None; - let _ = draw_tx_clone.send(()); - } + // Do not send a draw immediately here. By continuing the loop, + // we recompute the sleep target so the draw fires once via the + // sleep branch, coalescing multiple requests into a single draw. + continue; } None => break, } @@ -403,7 +403,10 @@ impl Tui { ) -> Result> { match action { ResumeAction::RealignInline => { - let cursor_pos = self.terminal.get_cursor_position()?; + let cursor_pos = self + .terminal + .get_cursor_position() + .unwrap_or(self.terminal.last_known_cursor_pos); Ok(Some(PreparedResumeAction::RealignViewport( ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0), ))) @@ -496,8 +499,9 @@ impl Tui { let terminal = &mut self.terminal; let screen_size = terminal.size()?; let last_known_screen_size = terminal.last_known_screen_size; - if screen_size != last_known_screen_size { - let cursor_pos = terminal.get_cursor_position()?; + if screen_size != last_known_screen_size + && let Ok(cursor_pos) = terminal.get_cursor_position() + { let last_known_cursor_pos = terminal.last_known_cursor_pos; if cursor_pos.y != last_known_cursor_pos.y { let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; @@ -510,6 +514,7 @@ impl Tui { } } + // Use synchronized update via backend instead of stdout() std::io::stdout().sync_update(|_| { #[cfg(unix)] { @@ -560,8 +565,7 @@ impl Tui { } terminal.draw(|frame| { draw_fn(frame); - })?; - Ok(()) + }) })? } } diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index fae170ff1a..39d084b407 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::path::PathBuf; use codex_core::config::Config; -use codex_core::user_agent::get_codex_user_agent; +use codex_core::default_client::create_client; pub fn get_upgrade_version(config: &Config) -> Option { let version_file = version_filepath(config); @@ -22,8 +22,9 @@ pub fn get_upgrade_version(config: &Config) -> Option { // Refresh the cached latest version in the background so TUI startup // isn’t blocked by a network call. The UI reads the previously cached // value (if any) for this run; the next run shows the banner if needed. + let originator = config.responses_originator_header.clone(); tokio::spawn(async move { - check_for_update(&version_file) + check_for_update(&version_file, &originator) .await .inspect_err(|e| tracing::error!("Failed to update version: {e}")) }); @@ -63,12 +64,11 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { Ok(serde_json::from_str(&contents)?) } -async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { +async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Result<()> { let ReleaseInfo { tag_name: latest_tag_name, - } = reqwest::Client::new() + } = create_client(originator) .get(LATEST_RELEASE_URL) - .header("User-Agent", get_codex_user_agent(None)) .send() .await? .error_for_status()? diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index e323574cd0..9ea2c0bc46 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -29,6 +29,7 @@ use ratatui::widgets::Wrap; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; /// Request coming from the agent that needs user approval. pub(crate) enum ApprovalRequest { @@ -114,10 +115,7 @@ fn to_command_display<'a>( cmd: String, last_line: Vec>, ) -> Vec> { - let command_lines: Vec = cmd - .lines() - .map(|line| Span::from(line.to_string()).style(Style::new().add_modifier(Modifier::DIM))) - .collect(); + let command_lines: Vec = cmd.lines().map(|line| line.to_string().dim()).collect(); let mut lines: Vec> = vec![]; @@ -127,7 +125,7 @@ fn to_command_display<'a>( first_line.extend(last_line); } else { for line in command_lines { - lines.push(Line::from(vec![Span::from(" "), line])); + lines.push(vec![" ".into(), line].into()); } let last_line = last_line.clone(); lines.push(Line::from(last_line)); @@ -258,12 +256,11 @@ impl UserApprovalWidget { } fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) { - let mut lines: Vec> = vec![Line::from("")]; match &self.approval_request { ApprovalRequest::Exec { command, .. } => { let cmd = strip_bash_lc_and_escape(command); - let mut cmd_span: Span = cmd.clone().into(); - cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM); + // TODO: move this rendering into history_cell. + let mut lines: Vec> = vec![]; // Result line based on decision. match decision { @@ -316,18 +313,22 @@ impl UserApprovalWidget { )); } } + + if !feedback.trim().is_empty() { + lines.push(Line::from("feedback:")); + for l in feedback.lines() { + lines.push(Line::from(l.to_string())); + } + } + + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_user_approval_decision(lines), + ))); } ApprovalRequest::ApplyPatch { .. } => { - lines.push(Line::from(format!("patch approval decision: {decision:?}"))); + // No history line for patch approval decisions. } } - if !feedback.trim().is_empty() { - lines.push(Line::from("feedback:")); - for l in feedback.lines() { - lines.push(Line::from(l.to_string())); - } - } - self.app_event_tx.send(AppEvent::InsertHistoryLines(lines)); let op = match &self.approval_request { ApprovalRequest::Exec { id, .. } => Op::ExecApproval { diff --git a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt deleted file mode 100644 index 56ed4a46a7..0000000000 --- a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt +++ /dev/null @@ -1,208 +0,0 @@ -To get started, describe a task or try one of these commands: - - /init - create an AGENTS.md file with instructions for Codex - /status - show current session configuration and token usage - /approvals - choose what Codex can do without approval - /model - choose what model and reasoning effort to use - -codex -I’m going to scan the workspace and Cargo manifests to see build profiles and -dependencies that impact binary size. Then I’ll summarize the main causes. - -_ - ✓ ls -la - └ total 6696 - drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 . - drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 .. - -rw-r--r--@ 1 easong staff 10244 Aug 8 21:13 .DS_Store - -rw-r--r--@ 1 easong staff 212 Aug 4 18:49 .gitignore -… +30 lines - -rw-r--r--@ 1 easong staff 9971 Aug 8 23:07 simpler.md - drwxr-xr-x@ 6 easong staff 192 Aug 4 22:40 target - drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui - -rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md - -rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md - ✓ sed -n '1,200p' Cargo.toml - └ [workspace] - members = [ - "ansi-escape", - "apply-patch", - "arg0", -… +38 lines - codegen-units = 1 - - [patch.crates-io] - # ratatui = { path = "../../ratatui" } - ratatui = { git = "https://github.com/nornagon/ratatui", branch = -"nornagon-v0.29.0-patch" } - ✓ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy -file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do -echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done - └ --- ansi-escape/Cargo.toml - [package] - edition = "2024" - name = "codex-ansi-escape" - version = { workspace = true } -… +237 lines - "process", - "rt-multi-thread", - "signal", - ] } - tracing = { version - ✓ sed -n '1,200p' execpolicy/Cargo.toml - └ [package] - name = "codex-execpolicy" - version = { workspace = true } - edition = "2024" - -… +23 lines - serde_json = "1.0.142" - serde_with = { version = "3", features = ["macros"] } - - [dev-dependencies] - tempfile = "3.13.0" - ✓ sed -n '1,200p' file-search/Cargo.toml - └ [package] - edition = "2024" - name = "codex-file-search" - version = { workspace = true } - -… +11 lines - ignore = "0.4.23" - nucleo-matcher = "0.3.1" - serde = { version = "1", features = ["derive"] } - serde_json = "1.0.142" - tokio = { version = "1", features = ["full"] } - ✓ sed -n '1,200p' linux-sandbox/Cargo.toml - └ [package] - edition = "2024" - name = "codex-linux-sandbox" - version = { workspace = true } - -… +24 lines - "macros", - "process", - "rt-multi-thread", - "signal", - ] } - ✓ sed -n '1,200p' login/Cargo.toml - └ [package] - edition = "2024" - name = "codex-login" - version = { workspace = true } - -… +17 lines - ] } - - [dev-dependencies] - pretty_assertions = "1.4.1" - tempfile = "3" - ✓ sed -n '1,200p' mcp-client/Cargo.toml - └ [package] - name = "codex-mcp-client" - version = { workspace = true } - edition = "2024" - -… +13 lines - "process", - "rt-multi-thread", - "sync", - "time", - ] } - ✓ sed -n '1,200p' mcp-server/Cargo.toml - └ [package] - edition = "2024" - name = "codex-mcp-server" - version = { workspace = true } - -… +35 lines - mcp_test_support = { path = "tests/common" } - pretty_assertions = "1.4.1" - tempfile = "3" - tokio-test = "0.4" - wiremock = "0.6" - ✓ sed -n '1,200p' mcp-types/Cargo.toml - └ [package] - edition = "2024" - name = "mcp-types" - version = { workspace = true } - -… +1 lines - workspace = true - - [dependencies] - serde = { version = "1", features = ["derive"] } - serde_json = "1" - ✓ sed -n '1,200p' ollama/Cargo.toml - └ [package] - edition = "2024" - name = "codex-ollama" - version = { workspace = true } - -… +22 lines - tracing = { version = "0.1.41", features = ["log"] } - wiremock = "0.6" - - [dev-dependencies] - tempfile = "3" - ✓ sed -n '1,200p' tui/Cargo.toml - └ [package] - edition = "2024" - name = "codex-tui" - version = { workspace = true } - -… +74 lines - chrono = { version = "0.4", features = ["serde"] } - insta = "1.43.1" - pretty_assertions = "1" - rand = "0.8" - vt100 = "0.16.2" - ✓ sed -n '1,200p' chatgpt/Cargo.toml - └ [package] - edition = "2024" - name = "codex-chatgpt" - version = { workspace = true } - -… +11 lines - serde_json = "1" - tokio = { version = "1", features = ["full"] } - - [dev-dependencies] - tempfile = "3" - -codex -Here’s what’s driving size in this workspace’s binaries. - -Main Causes - -- Static linking style: Each bin (codex, codex-tui, codex-exec, -codex-mcp-server, etc.) statically links its full dependency graph, so common -code isn’t shared at runtime across executables. -- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, and a TLS -stack (rustls by default; OpenSSL on musl). In core, login, tui, and ollama you -enable reqwest with json/stream, which still pulls a large transitive set. -- Image/terminal stack: codex-tui includes image (with jpeg), ratatui, -crossterm, and ratatui-image, which together contribute significant code size -(decoders, pixel ops, terminal backends). -- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and -starlark (in execpolicy) include sizeable parser/VM tables and runtimes. -- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, signal) -across many crates inflate code size even if only subsets are used per bin. -- Panic + backtraces: Default panic = unwind and backtrace support keep -unwinding tables and symbols that add weight. -- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-sys -with vendored, compiling OpenSSL into the binary—this adds multiple megabytes -per executable. - -Build-Mode Notes - -- Release settings: You use lto = "fat" and codegen-units = 1 (good for size), -but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single -contributor; if you build in release with that setting, binaries can still be -large. -- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and -assertions—outputs are much larger than cargo build --release. - -If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level -= "z", panic abort, tighter tokio/reqwest features) and estimate impact per -binary. \ No newline at end of file diff --git a/codex-rs/tui/tests/suite/vt100_history.rs b/codex-rs/tui/tests/suite/vt100_history.rs index 1f01a6d3ba..973b0aa052 100644 --- a/codex-rs/tui/tests/suite/vt100_history.rs +++ b/codex-rs/tui/tests/suite/vt100_history.rs @@ -3,10 +3,8 @@ use ratatui::backend::TestBackend; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; // Small helper macro to assert a collection contains an item with a clearer // failure message. @@ -80,7 +78,7 @@ fn basic_insertion_no_wrap() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("first"), Line::from("second")]; + let lines = vec!["first".into(), "second".into()]; let buf = scenario.run_insert(lines); let rows = scenario.screen_rows_from_bytes(&buf); assert_contains!(rows, String::from("first")); @@ -102,7 +100,7 @@ fn long_token_wraps() { let mut scenario = TestScenario::new(20, 6, area); let long = "A".repeat(45); // > 2 lines at width 20 - let lines = vec![Line::from(long.clone())]; + let lines = vec![long.clone().into()]; let buf = scenario.run_insert(lines); let mut parser = vt100::Parser::new(6, 20, 0); parser.process(&buf); @@ -134,7 +132,7 @@ fn emoji_and_cjk() { let mut scenario = TestScenario::new(20, 6, area); let text = String::from("😀😀😀😀😀 你好世界"); - let lines = vec![Line::from(text.clone())]; + let lines = vec![text.clone().into()]; let buf = scenario.run_insert(lines); let rows = scenario.screen_rows_from_bytes(&buf); let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect(); @@ -151,10 +149,7 @@ fn mixed_ansi_spans() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let line = Line::from(vec![ - Span::styled("red", Style::default().fg(Color::Red)), - Span::raw("+plain"), - ]); + let line = vec!["red".red(), "+plain".into()].into(); let buf = scenario.run_insert(vec![line]); let rows = scenario.screen_rows_from_bytes(&buf); assert_contains!(rows, String::from("red+plain")); @@ -165,7 +160,7 @@ fn cursor_restoration() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("x")]; + let lines = vec!["x".into()]; let buf = scenario.run_insert(lines); let s = String::from_utf8_lossy(&buf); // CUP to 1;1 (ANSI: ESC[1;1H) @@ -187,7 +182,7 @@ fn word_wrap_no_mid_word_split() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; - let buf = scenario.run_insert(vec![Line::from(sample)]); + let buf = scenario.run_insert(vec![sample.into()]); let rows = scenario.screen_rows_from_bytes(&buf); let joined = rows.join("\n"); assert!( @@ -203,7 +198,7 @@ fn em_dash_and_space_word_wrap() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed."; - let buf = scenario.run_insert(vec![Line::from(sample)]); + let buf = scenario.run_insert(vec![sample.into()]); let rows = scenario.screen_rows_from_bytes(&buf); let joined = rows.join("\n"); assert!( @@ -218,7 +213,7 @@ fn pre_scroll_region_down() { let area = Rect::new(0, 3, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("first"), Line::from("second")]; + let lines = vec!["first".into(), "second".into()]; let buf = scenario.run_insert(lines); let s = String::from_utf8_lossy(&buf); // Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based) diff --git a/codex-rs/tui/tests/suite/vt100_live_commit.rs b/codex-rs/tui/tests/suite/vt100_live_commit.rs index c0cfb3211a..e06f10275c 100644 --- a/codex-rs/tui/tests/suite/vt100_live_commit.rs +++ b/codex-rs/tui/tests/suite/vt100_live_commit.rs @@ -24,10 +24,7 @@ fn live_001_commit_on_overflow() { // Keep the last 3 in the live ring; commit the first 2. let commit_rows = rb.drain_commit_ready(3); - let lines: Vec> = commit_rows - .into_iter() - .map(|r| Line::from(r.text)) - .collect(); + let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); let mut buf: Vec = Vec::new(); codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); @@ -80,10 +77,7 @@ fn live_002_pre_scroll_and_commit() { // Keep 3, commit 1. let commit_rows = rb.drain_commit_ready(3); - let lines: Vec> = commit_rows - .into_iter() - .map(|r| Line::from(r.text)) - .collect(); + let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); let mut buf: Vec = Vec::new(); codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); diff --git a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs b/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs index a359e77a08..79f9d2fb00 100644 --- a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs +++ b/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs @@ -2,7 +2,6 @@ use ratatui::backend::TestBackend; use ratatui::layout::Rect; -use ratatui::text::Line; fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal { let backend = TestBackend::new(20, 6); @@ -23,7 +22,7 @@ fn stream_commit_trickle_no_duplication() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut out1, - vec![Line::from("one")], + vec!["one".into()], ); // Step 2: later commit next row @@ -31,7 +30,7 @@ fn stream_commit_trickle_no_duplication() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut out2, - vec![Line::from("two")], + vec!["two".into()], ); let combined = [out1, out2].concat(); @@ -62,7 +61,7 @@ fn live_ring_rows_not_inserted_into_history() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut buf, - vec![Line::from("one"), Line::from("two")], + vec!["one".into(), "two".into()], ); // The live ring might display tail+head rows like ["two", "three"], diff --git a/docs/authentication.md b/docs/authentication.md index d318be708c..5eb5204097 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -8,6 +8,8 @@ If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API export OPENAI_API_KEY="your-api-key-here" ``` +This key must, at minimum, have write access to the Responses API. + ## Migrating to ChatGPT login from API key If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps: @@ -85,4 +87,4 @@ If you run Codex on a remote machine (VPS/server) without a local browser, the l ssh -L 1455:localhost:1455 @ ``` -Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server. \ No newline at end of file +Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server. diff --git a/docs/config.md b/docs/config.md index 7d0bd7d2b8..3e1f93df8f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -90,7 +90,7 @@ http_headers = { "X-Example-Header" = "example-value" } # This will add the HTTP header `X-Example-Features` with the value of the # `EXAMPLE_FEATURES` environment variable to each request to the model provider # _if_ the environment variable is set and its value is non-empty. -env_http_headers = { "X-Example-Features": "EXAMPLE_FEATURES" } +env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" } ``` ### Per-provider network tuning @@ -116,7 +116,7 @@ How many times Codex will retry a failed HTTP request to the model provider. Def #### stream_max_retries -Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `10`. +Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `5`. #### stream_idle_timeout_ms @@ -181,7 +181,7 @@ Here is an example of a `config.toml` that defines multiple profiles: ```toml model = "o3" -approval_policy = "unless-allow-listed" +approval_policy = "untrusted" disable_response_storage = false # Setting `profile` is equivalent to specifying `--profile o3` on the command @@ -572,8 +572,8 @@ Options that are specific to the TUI. | `model_provider` | string | Provider id from `model_providers` (default: `openai`). | | `model_context_window` | number | Context window tokens. | | `model_max_output_tokens` | number | Max output tokens. | -| `approval_policy` | `untrusted` | `on-failure` | `on-request` | `never` | When to prompt for approval. | -| `sandbox_mode` | `read-only` | `workspace-write` | `danger-full-access` | OS sandbox policy. | +| `approval_policy` | `untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt for approval. | +| `sandbox_mode` | `read-only` \| `workspace-write` \| `danger-full-access` | OS sandbox policy. | | `sandbox_workspace_write.writable_roots` | array | Extra writable roots in workspace‑write. | | `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). | | `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). | @@ -587,7 +587,7 @@ Options that are specific to the TUI. | `model_providers..name` | string | Display name. | | `model_providers..base_url` | string | API base URL. | | `model_providers..env_key` | string | Env var for API key. | -| `model_providers..wire_api` | `chat` | `responses` | Protocol used (default: `chat`). | +| `model_providers..wire_api` | `chat` \| `responses` | Protocol used (default: `chat`). | | `model_providers..query_params` | map | Extra query params (e.g., Azure `api-version`). | | `model_providers..http_headers` | map | Additional static headers. | | `model_providers..env_http_headers` | map | Headers sourced from env vars. | @@ -597,15 +597,15 @@ Options that are specific to the TUI. | `project_doc_max_bytes` | number | Max bytes to read from `AGENTS.md`. | | `profile` | string | Active profile name. | | `profiles..*` | various | Profile‑scoped overrides of the same keys. | -| `history.persistence` | `save-all` | `none` | History file persistence (default: `save-all`). | +| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | | `history.max_bytes` | number | Currently ignored (not enforced). | -| `file_opener` | `vscode` | `vscode-insiders` | `windsurf` | `cursor` | `none` | URI scheme for clickable citations (default: `vscode`). | +| `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | | `tui` | table | TUI‑specific options (reserved). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | -| `model_reasoning_effort` | `minimal` | `low` | `medium` | `high` | Responses API reasoning effort. | -| `model_reasoning_summary` | `auto` | `concise` | `detailed` | `none` | Reasoning summaries. | -| `model_verbosity` | `low` | `medium` | `high` | GPT‑5 text verbosity (Responses API). | +| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. | +| `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. | +| `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). | | `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | | `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. | | `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). | @@ -613,5 +613,5 @@ Options that are specific to the TUI. | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | | `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | -| `preferred_auth_method` | `chatgpt` | `apikey` | Select default auth method (default: `chatgpt`). | +| `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | diff --git a/docs/getting-started.md b/docs/getting-started.md index ba99743133..691caae0e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,7 +48,7 @@ You can give Codex extra instructions and guidance using `AGENTS.md` files. Code 2. `AGENTS.md` at repo root - shared project notes 3. `AGENTS.md` in the current working directory - sub-folder/feature specifics -For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](./agents.md). +For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](https://agents.md/). ### Tips & shortcuts