From e5fdb5b0fd159c0ea80e872ff2111b49391fbbc7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 17 Sep 2025 11:05:22 -0700 Subject: [PATCH 01/42] fix: specify --repo when calling gh (#3806) Often, `gh` infers `--repo` when it is run from a Git clone, but our `publish-npm` step is designed to avoid the overhead of cloning the repo, so add the `--repo` option explicitly to fix things. --- .github/workflows/rust-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 8e6ac6582c..e3776f149c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -260,6 +260,7 @@ jobs: tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-npm-${version}.tgz" \ --dir dist/npm From 208089e58e9e5c49a3669dc995d8854560480a66 Mon Sep 17 00:00:00 2001 From: Abhishek Bhardwaj Date: Wed, 17 Sep 2025 11:06:59 -0700 Subject: [PATCH 02/42] AGENTS.md: Add instruction to install missing commands (#3807) This change instructs the model to install any missing command. Else tokens are wasted when it tries to run commands that aren't available multiple times before installing them. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 697de7009f..288719447e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ In the codex-rs folder where the rust code lives: - Crate names are prefixed with `codex-`. For example, the `core` folder's crate is named `codex-core` - When using format! and you can inline variables into {}, always do that. +- Install any commands the repo relies on (for example `just`, `rg`, or `cargo-insta`) if they aren't already available before running instructions here. - Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`. - 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. From 530382db0512e72cdd4e94271e2cbdb1d14b0b73 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:23:46 -0700 Subject: [PATCH 03/42] Use agent reply text in turn notifications (#3756) Instead of "Agent turn complete", turn-complete notifications now include the first handful of chars from the agent's final message. --- codex-rs/tui/src/chatwidget.rs | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fb91d75456..7ab954f263 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -255,7 +255,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_task_complete(&mut self) { + fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize only that stream to flush any tail // without emitting stray headers for other streams. if self.stream.is_write_cycle_active() { @@ -270,7 +270,9 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). - self.notify(Notification::AgentTurnComplete); + self.notify(Notification::AgentTurnComplete { + response: last_agent_message.unwrap_or_default(), + }); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -1082,7 +1084,9 @@ impl ChatWidget { } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), - EventMsg::TaskComplete(TaskCompleteEvent { .. }) => self.on_task_complete(), + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + self.on_task_complete(last_agent_message) + } EventMsg::TokenCount(ev) => self.set_token_info(ev.info), EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { @@ -1479,7 +1483,7 @@ impl WidgetRef for &ChatWidget { } enum Notification { - AgentTurnComplete, + AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, } @@ -1487,7 +1491,10 @@ enum Notification { impl Notification { fn display(&self) -> String { match self { - Notification::AgentTurnComplete => "Agent turn complete".to_string(), + Notification::AgentTurnComplete { response } => { + Notification::agent_turn_preview(response) + .unwrap_or_else(|| "Agent turn complete".to_string()) + } Notification::ExecApprovalRequested { command } => { format!("Approval requested: {}", truncate_text(command, 30)) } @@ -1507,7 +1514,7 @@ impl Notification { fn type_name(&self) -> &str { match self { - Notification::AgentTurnComplete => "agent-turn-complete", + Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } => "approval-requested", } @@ -1519,8 +1526,26 @@ impl Notification { Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), } } + + fn agent_turn_preview(response: &str) -> Option { + let mut normalized = String::new(); + for part in response.split_whitespace() { + if !normalized.is_empty() { + normalized.push(' '); + } + normalized.push_str(part); + } + let trimmed = normalized.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) + } + } } +const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; + const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", From c9505488a120299b339814d73f57817ee79e114f Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Wed, 17 Sep 2025 16:48:20 -0700 Subject: [PATCH 04/42] chore: update "Codex CLI harness, sandboxing, and approvals" section (#3822) --- codex-rs/core/gpt_5_codex_prompt.md | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 2c49fafec6..9a298f460f 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -26,37 +26,41 @@ When using the planning tool: ## Codex CLI harness, sandboxing, and approvals -The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from. +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. -Filesystem sandboxing defines which files can be read or written. The options are: -- **read-only**: You can only read files. -- **workspace-write**: You can read files. You can write to files in this folder, but not outside it. -- **danger-full-access**: No filesystem sandboxing. +Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. -Network sandboxing defines whether network can be accessed without approval. Options are +Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: - **restricted**: Requires approval - **enabled**: No approval needed -Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -Approval options are +Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are - **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. - **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. - **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) - **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. -When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp) +When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) -When sandboxing is set to read-only, you'll need to request approval for any command that isn't a read. +When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. +Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. + +When requesting approval to execute 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 you need to enable `with_escalated_permissions` in the justification parameter + ## Special user requests - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. From 4c97eeb32a776a323f840f168ef96c2ad12b3178 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 10:43:45 +0100 Subject: [PATCH 05/42] bug: Ignore tests for now (#3777) Ignore flaky / long tests for now --- codex-rs/core/src/unified_exec/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 99b19796b8..8b81345d4d 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -557,6 +557,7 @@ mod tests { #[cfg(unix)] #[tokio::test] + #[ignore] // Ignored while we have a better way to test this. async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> { let manager = UnifiedExecSessionManager::default(); @@ -578,6 +579,7 @@ mod tests { #[cfg(unix)] #[tokio::test] + #[ignore] // Ignored while we have a better way to test this. async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> { let manager = UnifiedExecSessionManager::default(); let result = manager From d4aba772cbfb8df33ef5e3a326cd1ecf251385e9 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Sep 2025 07:37:03 -0700 Subject: [PATCH 06/42] Switch to uuid_v7 and tighten ConversationId usage (#3819) Make sure conversations have a timestamp. --- codex-rs/core/Cargo.toml | 9 ++- codex-rs/mcp-server/Cargo.toml | 2 +- .../mcp-server/src/codex_message_processor.rs | 6 +- codex-rs/mcp-server/src/message_processor.rs | 5 +- .../mcp-server/tests/suite/list_resume.rs | 2 +- .../mcp-server/tests/suite/send_message.rs | 2 +- codex-rs/protocol/Cargo.toml | 8 ++- codex-rs/protocol/src/mcp_protocol.rs | 61 +++++++++++++++---- codex-rs/protocol/src/protocol.rs | 3 +- 9 files changed, 73 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b4ed4a937a..9973a00442 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -4,9 +4,9 @@ name = "codex-core" version = { workspace = true } [lib] +doctest = false name = "codex_core" path = "src/lib.rs" -doctest = false [lints] workspace = true @@ -41,7 +41,12 @@ similar = "2.7.0" strum_macros = "0.27.2" tempfile = "3" thiserror = "2.0.16" -time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] } +time = { version = "0.3", features = [ + "formatting", + "parsing", + "local-offset", + "macros", +] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index e068ba648f..9eb5f6eba6 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -36,7 +36,7 @@ tokio = { version = "1", features = [ toml = "0.9" tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -uuid = { version = "1", features = ["serde", "v4"] } +uuid = { version = "1", features = ["serde", "v7"] } [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index ceb47889be..af0ffefeda 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -814,7 +814,7 @@ impl CodexMessageProcessor { return; }; - let required_suffix = format!("{}.jsonl", conversation_id.0); + let required_suffix = format!("{conversation_id}.jsonl"); let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -1414,13 +1414,13 @@ mod tests { #[test] fn extract_conversation_summary_prefers_plain_user_messages() { let conversation_id = - ConversationId(Uuid::parse_str("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap()); + ConversationId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap(); let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); let path = PathBuf::from("rollout.jsonl"); let head = vec![ json!({ - "id": conversation_id.0, + "id": conversation_id.to_string(), "timestamp": timestamp, "cwd": "/", "originator": "codex", diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index c696d4538b..5868d60fc0 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -36,7 +36,6 @@ use serde_json::json; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task; -use uuid::Uuid; pub(crate) struct MessageProcessor { codex_message_processor: CodexMessageProcessor, @@ -484,8 +483,8 @@ impl MessageProcessor { return; } }; - let conversation_id = match Uuid::parse_str(&conversation_id) { - Ok(id) => ConversationId::from(id), + let conversation_id = match ConversationId::from_string(&conversation_id) { + Ok(id) => id, Err(e) => { tracing::error!("Failed to parse conversation_id: {e}"); let result = CallToolResult { diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/mcp-server/tests/suite/list_resume.rs index 9b5748cab5..9302b42990 100644 --- a/codex-rs/mcp-server/tests/suite/list_resume.rs +++ b/codex-rs/mcp-server/tests/suite/list_resume.rs @@ -142,7 +142,7 @@ async fn test_list_and_resume_conversations() { } = to_response::(resume_resp) .expect("deserialize resumeConversation response"); // conversation id should be a valid UUID - let _: uuid::Uuid = conversation_id.into(); + assert!(!conversation_id.to_string().is_empty()); } fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) { diff --git a/codex-rs/mcp-server/tests/suite/send_message.rs b/codex-rs/mcp-server/tests/suite/send_message.rs index 8f607776fc..158cb12d1c 100644 --- a/codex-rs/mcp-server/tests/suite/send_message.rs +++ b/codex-rs/mcp-server/tests/suite/send_message.rs @@ -136,7 +136,7 @@ async fn test_send_message_session_not_found() { .expect("timeout") .expect("init"); - let unknown = ConversationId(uuid::Uuid::new_v4()); + let unknown = ConversationId::new(); let req_id = mcp .send_send_user_message_request(SendUserMessageParams { conversation_id: unknown, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index f88297a07b..bbe2ed3f65 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -23,8 +23,12 @@ strum = "0.27.2" strum_macros = "0.27.2" sys-locale = "0.3.2" tracing = "0.1.41" -ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl", "no-serde-warnings"] } -uuid = { version = "1", features = ["serde", "v4"] } +ts-rs = { version = "11", features = [ + "uuid-impl", + "serde-json-impl", + "no-serde-warnings", +] } +uuid = { version = "1", features = ["serde", "v7"] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index fe77d20b66..c9137d0f94 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -19,13 +19,23 @@ use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] -pub struct ConversationId(pub Uuid); +pub struct ConversationId { + uuid: Uuid, +} impl ConversationId { pub fn new() -> Self { - Self(Uuid::new_v4()) + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) } } @@ -37,19 +47,27 @@ impl Default for ConversationId { impl Display for ConversationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + write!(f, "{}", self.uuid) } } -impl From for ConversationId { - fn from(value: Uuid) -> Self { - Self(value) +impl Serialize for ConversationId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) } } -impl From for Uuid { - fn from(value: ConversationId) -> Self { - value.0 +impl<'de> Deserialize<'de> for ConversationId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; + Ok(Self { uuid }) } } @@ -719,6 +737,27 @@ mod tests { #[test] fn test_conversation_id_default_is_not_zeroes() { let id = ConversationId::default(); - assert_ne!(id.0, Uuid::nil()); + assert_ne!(id.uuid, Uuid::nil()); + } + + #[test] + fn conversation_id_serializes_as_plain_string() { + let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + + assert_eq!( + json!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + serde_json::to_value(id).unwrap() + ); + } + + #[test] + fn conversation_id_deserializes_from_plain_string() { + let id: ConversationId = + serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8")).unwrap(); + + assert_eq!( + ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(), + id, + ); } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c3aebcdd42..dddaeee23f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1252,7 +1252,8 @@ mod tests { /// amount of nesting. #[test] fn serialize_event() { - let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")); + let conversation_id = + ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); let rollout_file = NamedTempFile::new().unwrap(); let event = Event { id: "1234".to_string(), From 1b3c8b8e946c453d5e1e68eb7e81a2546c3135db Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 16:27:15 +0100 Subject: [PATCH 07/42] Unify animations (#3729) Unify the animation in a single code and add the CTRL + . in the onboarding --- codex-rs/tui/src/ascii_animation.rs | 110 +++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/new_model_popup.rs | 50 ++------ .../tui/src/onboarding/onboarding_screen.rs | 28 +++-- codex-rs/tui/src/onboarding/welcome.rs | 112 +++++++++++++----- 5 files changed, 220 insertions(+), 81 deletions(-) create mode 100644 codex-rs/tui/src/ascii_animation.rs diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs new file mode 100644 index 0000000000..ee90191181 --- /dev/null +++ b/codex-rs/tui/src/ascii_animation.rs @@ -0,0 +1,110 @@ +use std::convert::TryFrom; +use std::time::Duration; +use std::time::Instant; + +use rand::Rng as _; + +use crate::frames::ALL_VARIANTS; +use crate::frames::FRAME_TICK_DEFAULT; +use crate::tui::FrameRequester; + +/// Drives ASCII art animations shared across popups and onboarding widgets. +pub(crate) struct AsciiAnimation { + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + frame_tick: Duration, + start: Instant, +} + +impl AsciiAnimation { + pub(crate) fn new(request_frame: FrameRequester) -> Self { + Self::with_variants(request_frame, ALL_VARIANTS, 0) + } + + pub(crate) fn with_variants( + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + ) -> Self { + assert!( + !variants.is_empty(), + "AsciiAnimation requires at least one animation variant", + ); + let clamped_idx = variant_idx.min(variants.len() - 1); + Self { + request_frame, + variants, + variant_idx: clamped_idx, + frame_tick: FRAME_TICK_DEFAULT, + start: Instant::now(), + } + } + + pub(crate) fn schedule_next_frame(&self) { + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + self.request_frame.schedule_frame(); + return; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let rem_ms = elapsed_ms % tick_ms; + let delay_ms = if rem_ms == 0 { + tick_ms + } else { + tick_ms - rem_ms + }; + if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { + self.request_frame + .schedule_frame_in(Duration::from_millis(delay_ms_u64)); + } else { + self.request_frame.schedule_frame(); + } + } + + pub(crate) fn current_frame(&self) -> &'static str { + let frames = self.frames(); + if frames.is_empty() { + return ""; + } + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + return frames[0]; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; + frames[idx] + } + + pub(crate) fn pick_random_variant(&mut self) -> bool { + if self.variants.len() <= 1 { + return false; + } + let mut rng = rand::rng(); + let mut next = self.variant_idx; + while next == self.variant_idx { + next = rng.random_range(0..self.variants.len()); + } + self.variant_idx = next; + self.request_frame.schedule_frame(); + true + } + + pub(crate) fn request_frame(&self) { + self.request_frame.schedule_frame(); + } + + fn frames(&self) -> &'static [&'static str] { + self.variants[self.variant_idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_tick_must_be_nonzero() { + assert!(FRAME_TICK_DEFAULT.as_millis() > 0); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 995ca17a6b..3f4d66ee4d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -32,6 +32,7 @@ mod app; mod app_backtrack; mod app_event; mod app_event_sender; +mod ascii_animation; mod backtrack_helpers; mod bottom_pane; mod chatwidget; diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs index 12325074c0..2106e46c67 100644 --- a/codex-rs/tui/src/new_model_popup.rs +++ b/codex-rs/tui/src/new_model_popup.rs @@ -1,5 +1,4 @@ -use crate::frames::ALL_VARIANTS as FRAME_VARIANTS; -use crate::frames::FRAME_TICK_DEFAULT; +use crate::ascii_animation::AsciiAnimation; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; @@ -7,7 +6,6 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; -use rand::Rng as _; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -17,10 +15,8 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use std::time::Duration; use tokio_stream::StreamExt; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 24; const MIN_ANIMATION_WIDTH: u16 = 60; @@ -39,9 +35,7 @@ enum ModelUpgradeOption { struct ModelUpgradePopup { highlighted: ModelUpgradeOption, decision: Option, - request_frame: FrameRequester, - frame_idx: usize, - variant_idx: usize, + animation: AsciiAnimation, } impl ModelUpgradePopup { @@ -49,9 +43,7 @@ impl ModelUpgradePopup { Self { highlighted: ModelUpgradeOption::TryNewModel, decision: None, - request_frame, - frame_idx: 0, - variant_idx: 0, + animation: AsciiAnimation::new(request_frame), } } @@ -65,7 +57,7 @@ impl ModelUpgradePopup { KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), KeyCode::Char('.') => { if key_event.modifiers.contains(KeyModifiers::CONTROL) { - self.pick_random_variant(); + let _ = self.animation.pick_random_variant(); } } _ => {} @@ -75,37 +67,13 @@ impl ModelUpgradePopup { fn highlight(&mut self, option: ModelUpgradeOption) { if self.highlighted != option { self.highlighted = option; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } fn select(&mut self, option: ModelUpgradeOption) { self.decision = Some(option.into()); - self.request_frame.schedule_frame(); - } - - fn advance_animation(&mut self) { - let len = self.frames().len(); - self.frame_idx = (self.frame_idx + 1) % len; - self.request_frame.schedule_frame_in(FRAME_TICK); - } - - fn frames(&self) -> &'static [&'static str] { - FRAME_VARIANTS[self.variant_idx] - } - - fn pick_random_variant(&mut self) { - let total = FRAME_VARIANTS.len(); - if total <= 1 { - return; - } - let mut rng = rand::rng(); - let mut next = self.variant_idx; - while next == self.variant_idx { - next = rng.random_range(0..total); - } - self.variant_idx = next; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } @@ -121,6 +89,7 @@ impl From for ModelUpgradeDecision { impl WidgetRef for &ModelUpgradePopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); + self.animation.schedule_next_frame(); // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = @@ -128,7 +97,7 @@ impl WidgetRef for &ModelUpgradePopup { let mut lines: Vec = Vec::new(); if show_animation { - let frame = self.frames()[self.frame_idx]; + let frame = self.animation.current_frame(); lines.extend(frame.lines().map(|l| l.into())); // Spacer between animation and text content. lines.push("".into()); @@ -188,8 +157,6 @@ pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result Result popup.handle_key_event(key_event), TuiEvent::Draw => { - popup.advance_animation(); let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&popup, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 12a2b4b134..5806424b32 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -7,6 +7,7 @@ use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; +use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; @@ -24,7 +25,6 @@ use crate::tui::TuiEvent; use color_eyre::eyre::Result; use std::sync::Arc; use std::sync::RwLock; -use std::time::Instant; #[allow(clippy::large_enum_variant)] enum Step { @@ -73,11 +73,10 @@ impl OnboardingScreen { } = args; let cwd = config.cwd.clone(); let codex_home = config.codex_home; - let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { - is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), - request_frame: tui.frame_requester(), - start: Instant::now(), - })]; + let mut steps: Vec = vec![Step::Welcome(WelcomeWidget::new( + !matches!(login_status, LoginStatus::NotAuthenticated), + tui.frame_requester(), + ))]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), @@ -189,6 +188,13 @@ impl KeyboardHandler for OnboardingScreen { self.is_done = true; } _ => { + if let Some(Step::Welcome(widget)) = self + .steps + .iter_mut() + .find(|step| matches!(step, Step::Welcome(_))) + { + widget.handle_key_event(key_event); + } if let Some(active_step) = self.current_steps_mut().into_iter().last() { active_step.handle_key_event(key_event); } @@ -226,8 +232,12 @@ impl WidgetRef for &OnboardingScreen { for yy in 0..height { let mut any = false; for xx in 0..width { - let sym = tmp[(xx, yy)].symbol(); - if !sym.trim().is_empty() { + let cell = &tmp[(xx, yy)]; + let has_symbol = !cell.symbol().trim().is_empty(); + let has_style = cell.fg != Color::Reset + || cell.bg != Color::Reset + || !cell.modifier.is_empty(); + if has_symbol || has_style { any = true; break; } @@ -271,7 +281,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) => (), + Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 578e2b429f..c62b6402d8 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,60 +1,68 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use crate::frames::FRAME_TICK_DEFAULT; -use crate::frames::FRAMES_DEFAULT; +use crate::ascii_animation::AsciiAnimation; +use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; use super::onboarding_screen::StepState; -use std::time::Duration; -use std::time::Instant; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 20; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, - pub request_frame: FrameRequester, - pub start: Instant, + animation: AsciiAnimation, } -impl WidgetRef for &WelcomeWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let elapsed_ms = self.start.elapsed().as_millis(); - - // Align next draw to the next FRAME_TICK boundary to reduce jitter. +impl KeyboardHandler for WelcomeWidget { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Char('.') + && key_event.modifiers.contains(KeyModifiers::CONTROL) { - let tick_ms = FRAME_TICK.as_millis(); - let rem_ms = elapsed_ms % tick_ms; - let delay_ms = if rem_ms == 0 { - tick_ms - } else { - tick_ms - rem_ms - }; - // Safe cast: delay_ms < tick_ms and FRAME_TICK is small. - self.request_frame - .schedule_frame_in(Duration::from_millis(delay_ms as u64)); + tracing::warn!("Welcome background to press '.'"); + let _ = self.animation.pick_random_variant(); } + } +} + +impl WelcomeWidget { + pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self { + Self { + is_logged_in, + animation: AsciiAnimation::new(request_frame), + } + } +} + +impl WidgetRef for &WelcomeWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + self.animation.schedule_next_frame(); - let frames = &FRAMES_DEFAULT; - let idx = ((elapsed_ms / FRAME_TICK.as_millis()) % frames.len() as u128) as usize; // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); if show_animation { - let frame_line_count = frames[idx].lines().count(); - lines.reserve(frame_line_count + 2); - lines.extend(frames[idx].lines().map(|l| l.into())); + let frame = self.animation.current_frame(); + // let frame_line_count = frame.lines().count(); + // lines.reserve(frame_line_count + 2); + lines.extend(frame.lines().map(|l| l.into())); lines.push("".into()); } lines.push(Line::from(vec![ @@ -82,10 +90,54 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + static VARIANT_A: [&str; 1] = ["frame-a"]; + static VARIANT_B: [&str; 1] = ["frame-b"]; + static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B]; + + #[test] + fn welcome_renders_animation_on_first_draw() { + let widget = WelcomeWidget::new(false, FrameRequester::test_dummy()); + let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); + let mut buf = Buffer::empty(area); + (&widget).render(area, &mut buf); + + let mut found = false; + let mut last_non_empty: Option = None; + for y in 0..area.height { + for x in 0..area.width { + if !buf[(x, y)].symbol().trim().is_empty() { + found = true; + last_non_empty = Some(y); + break; + } + } + } + + assert!(found, "expected welcome animation to render characters"); + let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0); + assert!( + measured_rows >= MIN_ANIMATION_HEIGHT, + "expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}" + ); + } - /// A number of things break down if FRAME_TICK is zero. #[test] - fn frame_tick_must_be_nonzero() { - assert!(FRAME_TICK.as_millis() > 0); + fn ctrl_dot_changes_animation_variant() { + let mut widget = WelcomeWidget { + is_logged_in: false, + animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), + }; + + let before = widget.animation.current_frame(); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL)); + let after = widget.animation.current_frame(); + + assert_ne!( + before, after, + "expected ctrl+. to switch welcome animation variant" + ); } } From 4a5d6f7c7154fbdfd7731289409298df8a230c4d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 16:34:16 +0100 Subject: [PATCH 08/42] Make ESC button work when auto-compaction (#3857) Only emit a task finished when the compaction comes from a `/compact` --- codex-rs/core/src/codex/compact.rs | 34 +++++++++++++--------------- codex-rs/core/tests/suite/compact.rs | 15 ++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index a465f937d4..0e20646423 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -79,15 +79,29 @@ pub(super) async fn run_compact_task( input: Vec, compact_instructions: String, ) { + let start_event = Event { + id: sub_id.clone(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: turn_context.client.get_model_context_window(), + }), + }; + sess.send_event(start_event).await; run_compact_task_inner( - sess, + sess.clone(), turn_context, - sub_id, + sub_id.clone(), input, compact_instructions, true, ) .await; + let event = Event { + id: sub_id, + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }; + sess.send_event(event).await; } async fn run_compact_task_inner( @@ -98,15 +112,6 @@ async fn run_compact_task_inner( compact_instructions: String, remove_task_on_completion: bool, ) { - let model_context_window = turn_context.client.get_model_context_window(); - let start_event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window, - }), - }; - sess.send_event(start_event).await; - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let instructions_override = compact_instructions; let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); @@ -195,13 +200,6 @@ async fn run_compact_task_inner( }), }; sess.send_event(event).await; - let event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskComplete(TaskCompleteEvent { - last_agent_message: None, - }), - }; - sess.send_event(event).await; } fn content_items_to_text(content: &[ContentItem]) -> Option { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 361315f724..8db70f3559 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -861,8 +861,18 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ .await .unwrap(); + let mut auto_compact_lifecycle_events = Vec::new(); loop { let event = codex.next_event().await.unwrap(); + if event.id.starts_with("auto-compact-") + && matches!( + event.msg, + EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) + ) + { + auto_compact_lifecycle_events.push(event); + continue; + } if let EventMsg::TaskComplete(_) = &event.msg && !event.id.starts_with("auto-compact-") { @@ -870,6 +880,11 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ } } + assert!( + auto_compact_lifecycle_events.is_empty(), + "auto compact should not emit task lifecycle events" + ); + let request_bodies: Vec = responder .recorded_requests() .into_iter() From 84a0ba9bf5121d288bee58ba865fc9b962b7f1d3 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:28:32 -0700 Subject: [PATCH 09/42] hint for codex resume on tui exit (#3757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-16 at 4 25 19 PM --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/main.rs | 7 +++++-- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 14 ++++++++++++-- codex-rs/tui/src/lib.rs | 10 +++++++--- codex-rs/tui/src/main.rs | 20 +++++++++++++++++--- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 81fc8683cb..e3930b507a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -929,6 +929,7 @@ dependencies = [ "libc", "mcp-types", "once_cell", + "owo-colors", "path-clean", "pathdiff", "pretty_assertions", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ce98a3937..e66855fe73 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -177,8 +177,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let usage = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - if !usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + if !usage.token_usage.is_zero() { + println!( + "{}", + codex_core::protocol::FinalOutput::from(usage.token_usage) + ); } } Some(Subcommand::Exec(mut exec_cli)) => { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 06e892b720..c7a5315844 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -85,6 +85,7 @@ unicode-segmentation = "1.12.0" unicode-width = "0.1" url = "2" pathdiff = "0.2" +owo-colors = "4.2.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 205986c916..c28b2d2713 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,7 @@ use codex_core::config::persist_model_selection; use codex_core::model_family::find_family_for_model; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::mcp_protocol::ConversationId; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -33,6 +34,12 @@ use tokio::select; use tokio::sync::mpsc::unbounded_channel; // use uuid::Uuid; +#[derive(Debug, Clone)] +pub struct AppExitInfo { + pub token_usage: TokenUsage, + pub conversation_id: Option, +} + pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -70,7 +77,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, - ) -> Result { + ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); @@ -153,7 +160,10 @@ impl App { } } {} tui.terminal.clear()?; - Ok(app.token_usage()) + Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + }) } pub(crate) async fn handle_tui_event( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3f4d66ee4d..04ead75058 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,6 +4,7 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +pub use app::AppExitInfo; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -86,7 +87,7 @@ use codex_core::internal_storage::InternalStorage; pub async fn run_main( cli: Cli, codex_linux_sandbox_exe: Option, -) -> std::io::Result { +) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -258,7 +259,7 @@ async fn run_ratatui_app( mut internal_storage: InternalStorage, active_profile: Option, should_show_trust_screen: bool, -) -> color_eyre::Result { +) -> color_eyre::Result { let mut config = config; color_eyre::install()?; @@ -370,7 +371,10 @@ async fn run_ratatui_app( resume_picker::ResumeSelection::Exit => { restore(); session_log::log_session_end(); - return Ok(codex_core::protocol::TokenUsage::default()); + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + }); } other => other, } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 2dbd797dd4..a9b426fe45 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -3,6 +3,8 @@ use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; +use owo_colors::OwoColorize; +use supports_color::Stream; #[derive(Parser, Debug)] struct TopCli { @@ -21,9 +23,21 @@ fn main() -> anyhow::Result<()> { .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - let usage = run_main(inner, codex_linux_sandbox_exe).await?; - if !usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; + let token_usage = exit_info.token_usage; + let conversation_id = exit_info.conversation_id; + if !token_usage.is_zero() { + println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); + if let Some(session_id) = conversation_id { + let command = format!("codex resume {session_id}"); + let prefix = "To continue this session, run "; + let suffix = "."; + if supports_color::on(Stream::Stdout).is_some() { + println!("{}{}{}", prefix, command.cyan(), suffix); + } else { + println!("{prefix}{command}{suffix}"); + } + } } Ok(()) }) From 992b531180fb07dc983dc339a109a6abd4ce8c6b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 18:18:06 +0100 Subject: [PATCH 10/42] fix: some nit Rust reference issues (#3849) Fix some small references issue. No behavioural change. Just making the code cleaner --- codex-rs/core/src/client_common.rs | 9 ++++----- codex-rs/core/src/openai_tools.rs | 4 ++-- codex-rs/core/src/shell.rs | 24 ++++++++++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index eead654b9e..3a5eb5b16b 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -34,13 +34,11 @@ pub struct Prompt { } impl Prompt { - pub(crate) fn get_full_instructions(&self, model: &ModelFamily) -> Cow<'_, str> { + pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelFamily) -> Cow<'a, str> { let base = self .base_instructions_override .as_deref() .unwrap_or(model.base_instructions.deref()); - let mut sections: Vec<&str> = vec![base]; - // When there are no custom instructions, add apply_patch_tool_instructions if: // - the model needs special instructions (4.1) // AND @@ -54,9 +52,10 @@ impl Prompt { && model.needs_special_apply_patch_instructions && !is_apply_patch_tool_present { - sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS); + Cow::Owned(format!("{base}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}")) + } else { + Cow::Borrowed(base) } - Cow::Owned(sections.join("\n")) } pub(crate) fn get_formatted_input(&self) -> Vec { diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index f4d724815e..b57d1b7692 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -333,7 +333,7 @@ pub(crate) struct ApplyPatchToolArgs { /// Responses API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=responses pub fn create_tools_json_for_responses_api( - tools: &Vec, + tools: &[OpenAiTool], ) -> crate::error::Result> { let mut tools_json = Vec::new(); @@ -348,7 +348,7 @@ pub fn create_tools_json_for_responses_api( /// Chat Completions API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat pub(crate) fn create_tools_json_for_chat_completions_api( - tools: &Vec, + tools: &[OpenAiTool], ) -> crate::error::Result> { // We start with the JSON for the Responses API and than rewrite it to match // the chat completions tool call format. diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index cb278fdd46..28cf61dd94 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -32,15 +32,19 @@ pub enum Shell { impl Shell { pub fn format_default_shell_invocation(&self, command: Vec) -> Option> { match self { - Shell::Zsh(zsh) => { - format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path) - } - Shell::Bash(bash) => { - format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path) - } + Shell::Zsh(zsh) => format_shell_invocation_with_rc( + command.as_slice(), + &zsh.shell_path, + &zsh.zshrc_path, + ), + Shell::Bash(bash) => format_shell_invocation_with_rc( + command.as_slice(), + &bash.shell_path, + &bash.bashrc_path, + ), Shell::PowerShell(ps) => { // If model generated a bash command, prefer a detected bash fallback - if let Some(script) = strip_bash_lc(&command) { + if let Some(script) = strip_bash_lc(command.as_slice()) { return match &ps.bash_exe_fallback { Some(bash) => Some(vec![ bash.to_string_lossy().to_string(), @@ -102,7 +106,7 @@ impl Shell { } fn format_shell_invocation_with_rc( - command: &Vec, + command: &[String], shell_path: &str, rc_path: &str, ) -> Option> { @@ -118,8 +122,8 @@ fn format_shell_invocation_with_rc( Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command]) } -fn strip_bash_lc(command: &Vec) -> Option { - match command.as_slice() { +fn strip_bash_lc(command: &[String]) -> Option { + match command { // exactly three items [first, second, third] // first two must be "bash", "-lc" From 277fc6254e10b3a2eebbc089408666cea129b9b7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 18:21:52 +0100 Subject: [PATCH 11/42] chore: use tokio mutex and async function to prevent blocking a worker (#3850) ### Why Use `tokio::sync::Mutex` `std::sync::Mutex` are not _async-aware_. As a result, they will block the entire thread instead of just yielding the task. Furthermore they can be poisoned which is not the case of `tokio` Mutex. This allows the Tokio runtime to continue running other tasks while waiting for the lock, preventing deadlocks and performance bottlenecks. In general, this is preferred in async environment --- codex-rs/core/src/codex.rs | 163 +++++++++++++++-------------- codex-rs/core/src/codex/compact.rs | 17 +-- 2 files changed, 96 insertions(+), 84 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b3b75ec76a..2317ae3dc6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3,8 +3,6 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; -use std::sync::Mutex; -use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::time::Duration; @@ -31,6 +29,7 @@ use mcp_types::CallToolResult; use serde::Deserialize; use serde::Serialize; use serde_json; +use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio::task::AbortHandle; use tracing::debug; @@ -135,21 +134,6 @@ mod compact; use self::compact::build_compacted_history; use self::compact::collect_user_messages; -// A convenience extension trait for acquiring mutex locks where poisoning is -// unrecoverable and should abort the program. This avoids scattered `.unwrap()` -// calls on `lock()` while still surfacing a clear panic message when a lock is -// poisoned. -trait MutexExt { - fn lock_unchecked(&self) -> MutexGuard<'_, T>; -} - -impl MutexExt for Mutex { - fn lock_unchecked(&self) -> MutexGuard<'_, T> { - #[expect(clippy::expect_used)] - self.lock().expect("poisoned lock") - } -} - /// The high-level interface to the Codex system. /// It operates as a queue pair where you send submissions and receive events. pub struct Codex { @@ -272,7 +256,6 @@ struct State { pending_input: Vec, history: ConversationHistory, token_info: Option, - next_internal_sub_id: u64, } /// Context for an initialized model agent @@ -298,6 +281,7 @@ pub(crate) struct Session { codex_linux_sandbox_exe: Option, user_shell: shell::Shell, show_raw_agent_reasoning: bool, + next_internal_sub_id: AtomicU64, } /// The context needed for a single turn of the conversation. @@ -500,6 +484,7 @@ impl Session { codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + next_internal_sub_id: AtomicU64::new(0), }); // Dispatch the SessionConfiguredEvent first and then report any errors. @@ -528,16 +513,16 @@ impl Session { Ok((sess, turn_context)) } - pub fn set_task(&self, task: AgentTask) { - let mut state = self.state.lock_unchecked(); + pub async fn set_task(&self, task: AgentTask) { + let mut state = self.state.lock().await; if let Some(current_task) = state.current_task.take() { current_task.abort(TurnAbortReason::Replaced); } state.current_task = Some(task); } - pub fn remove_task(&self, sub_id: &str) { - let mut state = self.state.lock_unchecked(); + pub async fn remove_task(&self, sub_id: &str) { + let mut state = self.state.lock().await; if let Some(task) = &state.current_task && task.sub_id == sub_id { @@ -546,9 +531,9 @@ impl Session { } fn next_internal_sub_id(&self) -> String { - let mut state = self.state.lock_unchecked(); - let id = state.next_internal_sub_id; - state.next_internal_sub_id += 1; + let id = self + .next_internal_sub_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); format!("auto-compact-{id}") } @@ -571,7 +556,7 @@ impl Session { let reconstructed_history = self.reconstruct_history_from_rollout(turn_context, &rollout_items); if !reconstructed_history.is_empty() { - self.record_into_history(&reconstructed_history); + self.record_into_history(&reconstructed_history).await; } // If persisting, persist all rollout items as-is (recorder filters) @@ -604,7 +589,7 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.insert(sub_id, tx_approve) }; if prev_entry.is_some() { @@ -636,7 +621,7 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.insert(sub_id, tx_approve) }; if prev_entry.is_some() { @@ -656,9 +641,9 @@ impl Session { rx_approve } - pub fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.remove(sub_id) }; match entry { @@ -671,15 +656,15 @@ impl Session { } } - pub fn add_approved_command(&self, cmd: Vec) { - let mut state = self.state.lock_unchecked(); + pub async fn add_approved_command(&self, cmd: Vec) { + let mut state = self.state.lock().await; state.approved_commands.insert(cmd); } /// Records input items: always append to conversation history and /// persist these response items to rollout. async fn record_conversation_items(&self, items: &[ResponseItem]) { - self.record_into_history(items); + self.record_into_history(items).await; self.persist_rollout_response_items(items).await; } @@ -711,11 +696,9 @@ impl Session { } /// Append ResponseItems to the in-memory conversation history only. - fn record_into_history(&self, items: &[ResponseItem]) { - self.state - .lock_unchecked() - .history - .record_items(items.iter()); + async fn record_into_history(&self, items: &[ResponseItem]) { + let mut state = self.state.lock().await; + state.history.record_items(items.iter()); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -743,8 +726,8 @@ impl Session { async fn persist_rollout_items(&self, items: &[RolloutItem]) { let recorder = { - let guard = self.rollout.lock_unchecked(); - guard.as_ref().cloned() + let guard = self.rollout.lock().await; + guard.clone() }; if let Some(rec) = recorder && let Err(e) = rec.record_items(items).await @@ -753,12 +736,12 @@ impl Session { } } - fn update_token_usage_info( + async fn update_token_usage_info( &self, turn_context: &TurnContext, token_usage: &Option, ) -> Option { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; let info = TokenUsageInfo::new_or_append( &state.token_info, token_usage, @@ -973,13 +956,17 @@ impl Session { /// Build the full turn input by concatenating the current conversation /// history with additional items for this turn. - pub fn turn_input_with_history(&self, extra: Vec) -> Vec { - [self.state.lock_unchecked().history.contents(), extra].concat() + pub async fn turn_input_with_history(&self, extra: Vec) -> Vec { + let history = { + let state = self.state.lock().await; + state.history.contents() + }; + [history, extra].concat() } /// Returns the input if there was no task running to inject into - pub fn inject_input(&self, input: Vec) -> Result<(), Vec> { - let mut state = self.state.lock_unchecked(); + pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { + let mut state = self.state.lock().await; if state.current_task.is_some() { state.pending_input.push(input.into()); Ok(()) @@ -988,8 +975,8 @@ impl Session { } } - pub fn get_pending_input(&self) -> Vec { - let mut state = self.state.lock_unchecked(); + pub async fn get_pending_input(&self) -> Vec { + let mut state = self.state.lock().await; if state.pending_input.is_empty() { Vec::with_capacity(0) } else { @@ -1011,9 +998,9 @@ impl Session { .await } - fn interrupt_task(&self) { + pub async fn interrupt_task(&self) { info!("interrupt received: abort current task, if any"); - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.clear(); state.pending_input.clear(); if let Some(task) = state.current_task.take() { @@ -1021,6 +1008,16 @@ impl Session { } } + fn interrupt_task_sync(&self) { + if let Ok(mut state) = self.state.try_lock() { + state.pending_approvals.clear(); + state.pending_input.clear(); + if let Some(task) = state.current_task.take() { + task.abort(TurnAbortReason::Interrupted); + } + } + } + /// Spawn the configured notifier (if any) with the given JSON payload as /// the last argument. Failures are logged but otherwise ignored so that /// notification issues do not interfere with the main workflow. @@ -1053,7 +1050,7 @@ impl Session { impl Drop for Session { fn drop(&mut self) { - self.interrupt_task(); + self.interrupt_task_sync(); } } @@ -1184,7 +1181,7 @@ async fn submission_loop( debug!(?sub, "Submission"); match sub.op { Op::Interrupt => { - sess.interrupt_task(); + sess.interrupt_task().await; } Op::OverrideTurnContext { cwd, @@ -1277,11 +1274,11 @@ async fn submission_loop( } Op::UserInput { items } => { // attempt to inject input into current task - if let Err(items) = sess.inject_input(items) { + if let Err(items) = sess.inject_input(items).await { // no current task, spawn a new one let task = AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task); + sess.set_task(task).await; } } Op::UserTurn { @@ -1294,7 +1291,7 @@ async fn submission_loop( summary, } => { // attempt to inject input into current task - if let Err(items) = sess.inject_input(items) { + if let Err(items) = sess.inject_input(items).await { // Derive a fresh TurnContext for this turn using the provided overrides. let provider = turn_context.client.get_provider(); let auth_manager = turn_context.client.get_auth_manager(); @@ -1360,20 +1357,20 @@ async fn submission_loop( // no current task, spawn a new one with the per‑turn context let task = AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task); + sess.set_task(task).await; } } Op::ExecApproval { id, decision } => match decision { ReviewDecision::Abort => { - sess.interrupt_task(); + sess.interrupt_task().await; } - other => sess.notify_approval(&id, other), + other => sess.notify_approval(&id, other).await, }, Op::PatchApproval { id, decision } => match decision { ReviewDecision::Abort => { - sess.interrupt_task(); + sess.interrupt_task().await; } - other => sess.notify_approval(&id, other), + other => sess.notify_approval(&id, other).await, }, Op::AddToHistory { text } => { let id = sess.conversation_id; @@ -1452,15 +1449,19 @@ async fn submission_loop( } Op::Compact => { // Attempt to inject input into current task - if let Err(items) = sess.inject_input(vec![InputItem::Text { - text: compact::COMPACT_TRIGGER_TEXT.to_string(), - }]) { + if let Err(items) = sess + .inject_input(vec![InputItem::Text { + text: compact::COMPACT_TRIGGER_TEXT.to_string(), + }]) + .await + { compact::spawn_compact_task( sess.clone(), Arc::clone(&turn_context), sub.id, items, - ); + ) + .await; } } Op::Shutdown => { @@ -1468,7 +1469,10 @@ async fn submission_loop( // Gracefully flush and shutdown rollout recorder on session end so tests // that inspect the rollout file do not race with the background writer. - let recorder_opt = sess.rollout.lock_unchecked().take(); + let recorder_opt = { + let mut guard = sess.rollout.lock().await; + guard.take() + }; if let Some(rec) = recorder_opt && let Err(e) = rec.shutdown().await { @@ -1493,7 +1497,7 @@ async fn submission_loop( let sub_id = sub.id.clone(); // Flush rollout writes before returning the path so readers observe a consistent file. let (path, rec_opt) = { - let guard = sess.rollout.lock_unchecked(); + let guard = sess.rollout.lock().await; match guard.as_ref() { Some(rec) => (rec.get_rollout_path(), Some(rec.clone())), None => { @@ -1604,7 +1608,7 @@ async fn spawn_review_thread( // Clone sub_id for the upcoming announcement before moving it into the task. let sub_id_for_event = sub_id.clone(); let task = AgentTask::review(sess.clone(), tc.clone(), sub_id, input); - sess.set_task(task); + sess.set_task(task).await; // Announce entering review mode so UIs can switch modes. sess.send_event(Event { @@ -1675,6 +1679,7 @@ async fn run_task( // may support this, the model might not. let pending_input = sess .get_pending_input() + .await .into_iter() .map(ResponseItem::from) .collect::>(); @@ -1696,7 +1701,7 @@ async fn run_task( review_thread_history.clone() } else { sess.record_conversation_items(&pending_input).await; - sess.turn_input_with_history(pending_input) + sess.turn_input_with_history(pending_input).await }; let turn_input_messages: Vec = turn_input @@ -1908,7 +1913,7 @@ async fn run_task( .await; } - sess.remove_task(&sub_id); + sess.remove_task(&sub_id).await; let event = Event { id: sub_id, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }), @@ -2141,7 +2146,9 @@ async fn try_run_turn( response_id: _, token_usage, } => { - let info = sess.update_token_usage_info(turn_context, &token_usage); + let info = sess + .update_token_usage_info(turn_context, &token_usage) + .await; let _ = sess .send_event(Event { id: sub_id.to_string(), @@ -2475,7 +2482,10 @@ async fn handle_function_call( } }; let abs = turn_context.resolve_path(Some(args.path)); - let output = match sess.inject_input(vec![InputItem::LocalImage { path: abs }]) { + let output = match sess + .inject_input(vec![InputItem::LocalImage { path: abs }]) + .await + { Ok(()) => FunctionCallOutputPayload { content: "attached local image path".to_string(), success: Some(true), @@ -2789,7 +2799,7 @@ async fn handle_container_exec_with_params( } None => { let safety = { - let state = sess.state.lock_unchecked(); + let state = sess.state.lock().await; assess_command_safety( ¶ms.command, turn_context.approval_policy, @@ -2818,7 +2828,7 @@ async fn handle_container_exec_with_params( match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved => (), ReviewDecision::ApprovedForSession => { - sess.add_approved_command(params.command.clone()); + sess.add_approved_command(params.command.clone()).await; } ReviewDecision::Denied | ReviewDecision::Abort => { return ResponseInputItem::FunctionCallOutput { @@ -2991,7 +3001,7 @@ async fn handle_sandbox_error( // remainder of the session so future // executions skip the sandbox directly. // TODO(ragona): Isn't this a bug? It always saves the command in an | fork? - sess.add_approved_command(params.command.clone()); + sess.add_approved_command(params.command.clone()).await; // Inform UI we are retrying without sandbox. sess.notify_background_event(&sub_id, "retrying command without sandbox") .await; @@ -3356,7 +3366,7 @@ mod tests { }), )); - let actual = session.state.lock_unchecked().history.contents(); + let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); assert_eq!(expected, actual); } @@ -3369,7 +3379,7 @@ mod tests { session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), ); - let actual = session.state.lock_unchecked().history.contents(); + let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); assert_eq!(expected, actual); } @@ -3611,6 +3621,7 @@ mod tests { codex_linux_sandbox_exe: None, user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + next_internal_sub_id: AtomicU64::new(0), }; (session, turn_context) } diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 0e20646423..684fd44ed5 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use super::AgentTask; -use super::MutexExt; use super::Session; use super::TurnContext; use super::get_last_assistant_message_from_turn; @@ -37,7 +36,7 @@ struct HistoryBridgeTemplate<'a> { summary_text: &'a str, } -pub(super) fn spawn_compact_task( +pub(super) async fn spawn_compact_task( sess: Arc, turn_context: Arc, sub_id: String, @@ -50,7 +49,7 @@ pub(super) fn spawn_compact_task( input, SUMMARIZATION_PROMPT.to_string(), ); - sess.set_task(task); + sess.set_task(task).await; } pub(super) async fn run_inline_auto_compact_task( @@ -114,7 +113,9 @@ async fn run_compact_task_inner( ) { let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let instructions_override = compact_instructions; - let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); + let turn_input = sess + .turn_input_with_history(vec![initial_input_for_turn.clone().into()]) + .await; let prompt = Prompt { input: turn_input, @@ -173,10 +174,10 @@ async fn run_compact_task_inner( } if remove_task_on_completion { - sess.remove_task(&sub_id); + sess.remove_task(&sub_id).await; } let history_snapshot = { - let state = sess.state.lock_unchecked(); + let state = sess.state.lock().await; state.history.contents() }; let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); @@ -184,7 +185,7 @@ async fn run_compact_task_inner( let initial_context = sess.build_initial_context(turn_context.as_ref()); let new_history = build_compacted_history(initial_context, &user_messages, &summary_text); { - let mut state = sess.state.lock_unchecked(); + let mut state = sess.state.lock().await; state.history.replace(new_history); } @@ -288,7 +289,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - let mut state = sess.state.lock_unchecked(); + let mut state = sess.state.lock().await; state.history.record_items(std::slice::from_ref(&item)); } Ok(ResponseEvent::Completed { .. }) => { From 71038381aa0f51aa62e1a2bcc7cbf26a05b141f3 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:25:09 -0700 Subject: [PATCH 12/42] fix error on missing notifications in [tui] (#3867) Fixes #3811. --- codex-rs/core/src/config.rs | 14 ++++++++++++++ codex-rs/core/src/config_types.rs | 1 + 2 files changed, 15 insertions(+) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6a613abbba..c84d84e0ff 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1163,6 +1163,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { #[cfg(test)] mod tests { use crate::config_types::HistoryPersistence; + use crate::config_types::Notifications; use super::*; use pretty_assertions::assert_eq; @@ -1201,6 +1202,19 @@ persistence = "none" ); } + #[test] + fn tui_config_missing_notifications_field_defaults_to_disabled() { + let cfg = r#" +[tui] +"#; + + let parsed = toml::from_str::(cfg) + .expect("TUI config without notifications should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert_eq!(tui.notifications, Notifications::Enabled(false)); + } + #[test] fn test_sandbox_config_parsing() { let sandbox_full_access = r#" diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index ec8e8e67c6..3737649871 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -94,6 +94,7 @@ impl Default for Notifications { pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `false`. + #[serde(default)] pub notifications: Notifications, } From b34e9063963e52aa32a06f13a4034929d018d3cd Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:55:53 -0700 Subject: [PATCH 13/42] Reland "refactor transcript view to handle HistoryCells" (#3753) Reland of #3538 --- codex-rs/core/src/codex.rs | 9 +- codex-rs/core/src/codex/compact.rs | 4 +- codex-rs/core/src/conversation_manager.rs | 69 ++-- codex-rs/core/src/lib.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 19 +- .../core/tests/suite/fork_conversation.rs | 18 +- codex-rs/tui/src/app.rs | 21 +- codex-rs/tui/src/app_backtrack.rs | 269 +++++++++++----- codex-rs/tui/src/backtrack_helpers.rs | 153 --------- codex-rs/tui/src/history_cell.rs | 11 +- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/pager_overlay.rs | 302 +++++++++++------- ...ts__transcript_overlay_snapshot_basic.snap | 4 +- 13 files changed, 477 insertions(+), 405 deletions(-) delete mode 100644 codex-rs/tui/src/backtrack_helpers.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2317ae3dc6..1de37f6f6a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -130,7 +130,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::ShellToolCallParams; use codex_protocol::protocol::InitialHistory; -mod compact; +pub mod compact; use self::compact::build_compacted_history; use self::compact::collect_user_messages; @@ -710,7 +710,7 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { + pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(2); if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push(UserInstructions::new(user_instructions.to_string()).into()); @@ -3325,6 +3325,9 @@ async fn exit_review_mode( .await; } +#[cfg(test)] +pub(crate) use tests::make_session_and_context; + #[cfg(test)] mod tests { use super::*; @@ -3565,7 +3568,7 @@ mod tests { }) } - fn make_session_and_context() -> (Session, TurnContext) { + pub(crate) fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); let codex_home = tempfile::tempdir().expect("create temp dir"); let config = Config::load_from_base_config_with_overrides( diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 684fd44ed5..05d57cd945 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -203,7 +203,7 @@ async fn run_compact_task_inner( sess.send_event(event).await; } -fn content_items_to_text(content: &[ContentItem]) -> Option { +pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { match item { @@ -235,7 +235,7 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { .collect() } -fn is_session_prefix_message(text: &str) -> bool { +pub fn is_session_prefix_message(text: &str) -> bool { matches!( InputMessageKind::from(("user", text)), InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 7147eca233..51854248a4 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -3,6 +3,8 @@ use crate::CodexAuth; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; +use crate::codex::compact::content_items_to_text; +use crate::codex::compact::is_session_prefix_message; use crate::codex_conversation::CodexConversation; use crate::config::Config; use crate::error::CodexErr; @@ -134,19 +136,19 @@ impl ConversationManager { self.conversations.write().await.remove(conversation_id) } - /// Fork an existing conversation by dropping the last `drop_last_messages` - /// user/assistant messages from its transcript and starting a new + /// Fork an existing conversation by taking messages up to the given position + /// (not including the message at the given position) and starting a new /// conversation with identical configuration (unless overridden by the /// caller's `config`). The new conversation will have a fresh id. pub async fn fork_conversation( &self, - num_messages_to_drop: usize, + nth_user_message: usize, config: Config, path: PathBuf, ) -> CodexResult { // Compute the prefix up to the cut point. let history = RolloutRecorder::get_rollout_history(&path).await?; - let history = truncate_after_dropping_last_messages(history, num_messages_to_drop); + let history = truncate_before_nth_user_message(history, nth_user_message); // Spawn a new conversation with the computed initial history. let auth_manager = self.auth_manager.clone(); @@ -159,33 +161,30 @@ 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(history: InitialHistory, n: usize) -> InitialHistory { - if n == 0 { - return InitialHistory::Forked(history.get_rollout_items()); - } - - // Work directly on rollout items, and cut the vector at the nth-from-last user message input. +/// Return a prefix of `items` obtained by cutting strictly before the nth user message +/// (0-based) and all items that follow it. +fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory { + // Work directly on rollout items, and cut the vector at the nth user message input. let items: Vec = history.get_rollout_items(); // Find indices of user message inputs in rollout order. let mut user_positions: Vec = Vec::new(); for (idx, item) in items.iter().enumerate() { - if let RolloutItem::ResponseItem(ResponseItem::Message { role, .. }) = item + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item && role == "user" + && content_items_to_text(content).is_some_and(|text| !is_session_prefix_message(&text)) { user_positions.push(idx); } } - // If fewer than n user messages exist, treat as empty. - if user_positions.len() < n { + // If fewer than or equal to n user messages exist, treat as empty (out of range). + if user_positions.len() <= n { return InitialHistory::New; } - // Cut strictly before the nth-from-last user message (do not keep the nth itself). - let cut_idx = user_positions[user_positions.len() - n]; + // Cut strictly before the nth user message (do not keep the nth itself). + let cut_idx = user_positions[n]; let rolled: Vec = items.into_iter().take(cut_idx).collect(); if rolled.is_empty() { @@ -198,9 +197,11 @@ fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> I #[cfg(test)] mod tests { use super::*; + use crate::codex::make_session_and_context; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; + use pretty_assertions::assert_eq; fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { @@ -252,7 +253,7 @@ mod tests { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_after_dropping_last_messages(InitialHistory::Forked(initial), 1); + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); let got_items = truncated.get_rollout_items(); let expected_items = vec![ RolloutItem::ResponseItem(items[0].clone()), @@ -269,7 +270,37 @@ mod tests { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated2 = truncate_after_dropping_last_messages(InitialHistory::Forked(initial2), 2); + let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); assert!(matches!(truncated2, InitialHistory::New)); } + + #[test] + fn ignores_session_prefix_messages_when_truncating() { + let (session, turn_context) = make_session_and_context(); + let mut items = session.build_initial_context(&turn_context); + items.push(user_msg("feature request")); + items.push(assistant_msg("ack")); + items.push(user_msg("second question")); + items.push(assistant_msg("answer")); + + let rollout_items: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let got_items = truncated.get_rollout_items(); + + let expected: Vec = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + ]; + + assert_eq!( + serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e024effbe2..f73bef4051 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -92,6 +92,8 @@ pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; +pub use codex::compact::content_items_to_text; +pub use codex::compact::is_session_prefix_message; pub use codex_protocol::models::ContentItem; pub use codex_protocol::models::LocalShellAction; pub use codex_protocol::models::LocalShellExecAction; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 34b43ece91..1bedbcab08 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -74,7 +74,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "compact+resume test expects resumed path {resumed_path:?} to exist", ); - let forked = fork_conversation(&manager, &config, resumed_path, 1).await; + let forked = fork_conversation(&manager, &config, resumed_path, 2).await; user_turn(&forked, "AFTER_FORK").await; // 3. Capture the requests to the model and validate the history slices. @@ -100,17 +100,15 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "after-resume input should have at least as many items as after-compact", ); assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]); - eprint!( - "len of compact: {}, len of fork: {}", - compact_arr.len(), - fork_arr.len() - ); - eprintln!("input_after_fork:{}", json!(input_after_fork)); + assert!( compact_arr.len() <= fork_arr.len(), "after-fork input should have at least as many items as after-compact", ); - assert_eq!(compact_arr.as_slice(), &fork_arr[..compact_arr.len()]); + assert_eq!( + &compact_arr.as_slice()[..compact_arr.len()], + &fork_arr[..compact_arr.len()] + ); let prompt = requests[0]["instructions"] .as_str() @@ -824,14 +822,15 @@ async fn resume_conversation( conversation } +#[cfg(test)] async fn fork_conversation( manager: &ConversationManager, config: &Config, path: std::path::PathBuf, - back_steps: usize, + nth_user_message: usize, ) -> Arc { let NewConversation { conversation, .. } = manager - .fork_conversation(back_steps, config.clone(), path) + .fork_conversation(nth_user_message, config.clone(), path) .await .expect("fork conversation"); conversation diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 08e3f29d28..f3027811e8 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -5,6 +5,8 @@ use codex_core::ModelProviderInfo; use codex_core::NewConversation; use codex_core::ResponseItem; use codex_core::built_in_model_providers; +use codex_core::content_items_to_text; +use codex_core::is_session_prefix_message; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -104,13 +106,16 @@ async fn fork_conversation_twice_drops_to_first_message() { items }; - // Compute expected prefixes after each fork by truncating base rollout at nth-from-last user input. + // Compute expected prefixes after each fork by truncating base rollout + // strictly before the nth user input (0-based). let base_items = read_items(&base_path); let find_user_input_positions = |items: &[RolloutItem]| -> Vec { let mut pos = Vec::new(); for (i, it) in items.iter().enumerate() { if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = it && role == "user" + && content_items_to_text(content) + .is_some_and(|text| !is_session_prefix_message(&text)) { // Consider any user message as an input boundary; recorder stores both EventMsg and ResponseItem. // We specifically look for input items, which are represented as ContentItem::InputText. @@ -126,11 +131,8 @@ async fn fork_conversation_twice_drops_to_first_message() { }; let user_inputs = find_user_input_positions(&base_items); - // After dropping last user input (n=1), cut strictly before that input if present, else empty. - let cut1 = user_inputs - .get(user_inputs.len().saturating_sub(1)) - .copied() - .unwrap_or(0); + // After cutting at nth user input (n=1 → second user message), cut strictly before that input. + let cut1 = user_inputs.get(1).copied().unwrap_or(0); let expected_after_first: Vec = base_items[..cut1].to_vec(); // After dropping again (n=1 on fork1), compute expected relative to fork1's rollout. @@ -161,12 +163,12 @@ async fn fork_conversation_twice_drops_to_first_message() { serde_json::to_value(&expected_after_first).unwrap() ); - // Fork again with n=1 → drops the (new) last user message, leaving only the first. + // Fork again with n=0 → drops the (new) last user message, leaving only the first. let NewConversation { conversation: codex_fork2, .. } = conversation_manager - .fork_conversation(1, config_for_fork.clone(), fork1_path.clone()) + .fork_conversation(0, config_for_fork.clone(), fork1_path.clone()) .await .expect("fork 2"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c28b2d2713..bd9150cad0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; +use crate::history_cell::HistoryCell; use crate::pager_overlay::Overlay; use crate::resume_picker::ResumeSelection; use crate::tui; @@ -52,7 +53,7 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, - pub(crate) transcript_lines: Vec>, + pub(crate) transcript_cells: Vec>, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -138,7 +139,7 @@ impl App { active_profile, file_search, enhanced_keys_supported, - transcript_lines: Vec::new(), + transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -225,15 +226,12 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { - let mut cell_transcript = cell.transcript_lines(); - if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() { - cell_transcript.insert(0, Line::from("")); - } + let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_lines(cell_transcript.clone()); + t.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } - self.transcript_lines.extend(cell_transcript.clone()); + self.transcript_cells.push(cell.clone()); 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 @@ -380,7 +378,7 @@ impl App { } => { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode @@ -405,7 +403,7 @@ impl App { kind: KeyEventKind::Press, .. } if self.backtrack.primed - && self.backtrack.count > 0 + && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { // Delegate to helper for clarity; preserves behavior. @@ -439,7 +437,6 @@ mod tests { use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; - use ratatui::text::Line; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -462,7 +459,7 @@ mod tests { config, active_profile: None, file_search, - transcript_lines: Vec::>::new(), + transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 4e716c90ec..c07be57af1 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; +use std::sync::Arc; use crate::app::App; -use crate::backtrack_helpers; +use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; @@ -19,11 +20,11 @@ pub(crate) struct BacktrackState { pub(crate) primed: bool, /// Session id of the base conversation to fork from. pub(crate) base_id: Option, - /// Current step count (Nth last user message). - pub(crate) count: usize, + /// Index in the transcript of the last user message. + pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, drop_count, prefill). + /// Pending fork request: (base_id, nth_user_message, prefill). pub(crate) pending: Option<(ConversationId, usize, String)>, } @@ -96,9 +97,9 @@ impl App { &mut self, prefill: String, base_id: ConversationId, - drop_last_messages: usize, + nth_user_message: usize, ) { - self.backtrack.pending = Some((base_id, drop_last_messages, prefill)); + self.backtrack.pending = Some((base_id, nth_user_message, prefill)); self.app_event_tx.send(crate::app_event::AppEvent::CodexOp( codex_core::protocol::Op::GetPath, )); @@ -107,7 +108,7 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } @@ -130,15 +131,17 @@ impl App { /// Re-render the full transcript into the terminal scrollback in one call. /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { - if !self.transcript_lines.is_empty() { - tui.insert_history_lines(self.transcript_lines.clone()); + if !self.transcript_cells.is_empty() { + for cell in &self.transcript_cells { + tui.insert_history_lines(cell.transcript_lines()); + } } } /// Initialize backtrack state and show composer hint. fn prime_backtrack(&mut self) { self.backtrack.primed = true; - self.backtrack.count = 0; + self.backtrack.nth_user_message = usize::MAX; self.backtrack.base_id = self.chat_widget.conversation_id(); self.chat_widget.show_esc_backtrack_hint(); } @@ -157,51 +160,44 @@ impl App { self.backtrack.primed = true; self.backtrack.base_id = self.chat_widget.conversation_id(); self.backtrack.overlay_preview_active = true; - let sel = self.compute_backtrack_selection(tui, 1); - self.apply_backtrack_selection(sel); + let last_user_cell_position = self + .transcript_cells + .iter() + .filter_map(|c| c.as_any().downcast_ref::()) + .count() as i64 + - 1; + if last_user_cell_position >= 0 { + self.apply_backtrack_selection(last_user_cell_position as usize); + } tui.frame_requester().schedule_frame(); } /// Step selection to the next older user message and update overlay. fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let next = self.backtrack.count.saturating_add(1); - let sel = self.compute_backtrack_selection(tui, next); - self.apply_backtrack_selection(sel); + let last_user_cell_position = self + .transcript_cells + .iter() + .filter(|c| c.as_any().is::()) + .take(self.backtrack.nth_user_message) + .count() + .saturating_sub(1); + self.apply_backtrack_selection(last_user_cell_position); tui.frame_requester().schedule_frame(); } - /// Compute normalized target, scroll offset, and highlight for requested step. - fn compute_backtrack_selection( - &self, - tui: &tui::Tui, - requested_n: usize, - ) -> (usize, Option, Option<(usize, usize)>) { - let nth = backtrack_helpers::normalize_backtrack_n(&self.transcript_lines, requested_n); - let header_idx = - backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, nth); - let offset = header_idx.map(|idx| { - backtrack_helpers::wrapped_offset_before( - &self.transcript_lines, - idx, - tui.terminal.viewport_area.width, - ) - }); - let hl = backtrack_helpers::highlight_range_for_nth_last_user(&self.transcript_lines, nth); - (nth, offset, hl) - } - /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection( - &mut self, - selection: (usize, Option, Option<(usize, usize)>), - ) { - let (nth, offset, hl) = selection; - self.backtrack.count = nth; + fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { - if let Some(off) = offset { - t.set_scroll_offset(off); + let cell = self + .transcript_cells + .iter() + .enumerate() + .filter(|(_, c)| c.as_any().is::()) + .nth(nth_user_message); + if let Some((idx, _)) = cell { + t.set_highlight_cell(Some(idx)); } - t.set_highlight_range(hl); } } @@ -219,13 +215,19 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { + let nth_user_message = self.backtrack.nth_user_message; if let Some(base_id) = self.backtrack.base_id { - let drop_last_messages = self.backtrack.count; - let prefill = - backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages) - .unwrap_or_default(); + let user_cells = self + .transcript_cells + .iter() + .filter_map(|c| c.as_any().downcast_ref::()) + .collect::>(); + let prefill = user_cells + .get(nth_user_message) + .map(|c| c.message.clone()) + .unwrap_or_default(); self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, drop_last_messages); + self.request_backtrack(prefill, base_id, nth_user_message); } self.reset_backtrack_state(); } @@ -244,11 +246,15 @@ impl App { /// Computes the prefill from the selected user message and requests history. pub(crate) fn confirm_backtrack_from_main(&mut self) { if let Some(base_id) = self.backtrack.base_id { - let drop_last_messages = self.backtrack.count; - let prefill = - backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, drop_last_messages); + let prefill = self + .transcript_cells + .iter() + .filter(|c| c.as_any().is::()) + .nth(self.backtrack.nth_user_message) + .and_then(|c| c.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); } self.reset_backtrack_state(); } @@ -257,7 +263,7 @@ impl App { pub(crate) fn reset_backtrack_state(&mut self) { self.backtrack.primed = false; self.backtrack.base_id = None; - self.backtrack.count = 0; + self.backtrack.nth_user_message = usize::MAX; // In case a hint is somehow still visible (e.g., race with overlay open/close). self.chat_widget.clear_esc_backtrack_hint(); } @@ -271,9 +277,9 @@ impl App { ) -> Result<()> { if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() && ev.conversation_id == *base_id - && let Some((_, drop_count, prefill)) = self.backtrack.pending.take() + && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() { - self.fork_and_switch_to_new_conversation(tui, ev, drop_count, prefill) + self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) .await; } Ok(()) @@ -284,17 +290,17 @@ impl App { &mut self, tui: &mut tui::Tui, ev: ConversationPathResponseEvent, - drop_count: usize, + nth_user_message: usize, prefill: String, ) { let cfg = self.chat_widget.config_ref().clone(); // Perform the fork via a thin wrapper for clarity/testability. let result = self - .perform_fork(ev.path.clone(), drop_count, cfg.clone()) + .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) .await; match result { Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, drop_count, &prefill) + self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) } Err(e) => tracing::error!("error forking conversation: {e:#}"), } @@ -304,10 +310,12 @@ impl App { async fn perform_fork( &self, path: PathBuf, - drop_count: usize, + nth_user_message: usize, cfg: codex_core::config::Config, ) -> codex_core::error::Result { - self.server.fork_conversation(drop_count, cfg, path).await + self.server + .fork_conversation(nth_user_message, cfg, path) + .await } /// Install a forked conversation into the ChatWidget and update UI to reflect selection. @@ -316,7 +324,7 @@ impl App { tui: &mut tui::Tui, cfg: codex_core::config::Config, new_conv: codex_core::NewConversation, - drop_count: usize, + nth_user_message: usize, prefill: &str, ) { let conv = new_conv.conversation; @@ -333,7 +341,7 @@ impl App { self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(drop_count); + self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); if !prefill.is_empty() { self.chat_widget.set_composer_text(prefill.to_string()); @@ -341,14 +349,129 @@ impl App { tui.frame_requester().schedule_frame(); } - /// Trim transcript_lines to preserve only content up to the selected user message. - fn trim_transcript_for_backtrack(&mut self, drop_count: usize) { - if let Some(cut_idx) = - backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, drop_count) - { - self.transcript_lines.truncate(cut_idx); - } else { - self.transcript_lines.clear(); - } + /// Trim transcript_cells to preserve only content up to the selected user message. + fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { + trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); + } +} + +fn trim_transcript_cells_to_nth_user( + transcript_cells: &mut Vec>, + nth_user_message: usize, +) { + if nth_user_message == usize::MAX { + return; + } + + let cut_idx = transcript_cells + .iter() + .enumerate() + .filter_map(|(idx, cell)| cell.as_any().is::().then_some(idx)) + .nth(nth_user_message) + .unwrap_or(transcript_cells.len()); + transcript_cells.truncate(cut_idx); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use ratatui::prelude::Line; + use std::sync::Arc; + + #[test] + fn trim_transcript_for_first_user_drops_user_and_newer_cells() { + let mut cells: Vec> = vec![ + Arc::new(UserHistoryCell { + message: "first user".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert!(cells.is_empty()); + } + + #[test] + fn trim_transcript_preserves_cells_before_selected_user() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert_eq!(cells.len(), 1); + let agent = cells[0] + .as_any() + .downcast_ref::() + .expect("agent cell"); + let agent_lines = agent.display_lines(u16::MAX); + assert_eq!(agent_lines.len(), 1); + let intro_text: String = agent_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "> intro"); + } + + #[test] + fn trim_transcript_for_later_user_keeps_prior_history() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) + as Arc, + Arc::new(UserHistoryCell { + message: "second".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 1); + + assert_eq!(cells.len(), 3); + let agent_intro = cells[0] + .as_any() + .downcast_ref::() + .expect("intro agent"); + let intro_lines = agent_intro.display_lines(u16::MAX); + let intro_text: String = intro_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "> intro"); + + let user_first = cells[1] + .as_any() + .downcast_ref::() + .expect("first user"); + assert_eq!(user_first.message, "first"); + + let agent_between = cells[2] + .as_any() + .downcast_ref::() + .expect("between agent"); + let between_lines = agent_between.display_lines(u16::MAX); + let between_text: String = between_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(between_text, " between"); } } diff --git a/codex-rs/tui/src/backtrack_helpers.rs b/codex-rs/tui/src/backtrack_helpers.rs deleted file mode 100644 index e2306b80b1..0000000000 --- a/codex-rs/tui/src/backtrack_helpers.rs +++ /dev/null @@ -1,153 +0,0 @@ -use ratatui::text::Line; - -/// Convenience: compute the highlight range for the Nth last user message. -pub(crate) fn highlight_range_for_nth_last_user( - lines: &[Line<'_>], - n: usize, -) -> Option<(usize, usize)> { - let header = find_nth_last_user_header_index(lines, n)?; - Some(highlight_range_from_header(lines, header)) -} - -/// Compute the wrapped display-line offset before `header_idx`, for a given width. -pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize { - let before = &lines[0..header_idx]; - crate::wrapping::word_wrap_lines(before, width as usize).len() -} - -/// Find the header index for the Nth last user message in the transcript. -/// Returns `None` if `n == 0` or there are fewer than `n` user messages. -pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option { - if n == 0 { - return None; - } - let mut found = 0usize; - for (idx, line) in lines.iter().enumerate().rev() { - let content: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - if content.trim() == "user" { - found += 1; - if found == n { - return Some(idx); - } - } - } - None -} - -/// Normalize a requested backtrack step `n` against the available user messages. -/// - Returns `0` if there are no user messages. -/// - Returns `n` if the Nth last user message exists. -/// - Otherwise wraps to `1` (the most recent user message). -pub(crate) fn normalize_backtrack_n(lines: &[Line<'_>], n: usize) -> usize { - if n == 0 { - return 0; - } - if find_nth_last_user_header_index(lines, n).is_some() { - return n; - } - if find_nth_last_user_header_index(lines, 1).is_some() { - 1 - } else { - 0 - } -} - -/// Extract the text content of the Nth last user message. -/// The message body is considered to be the lines following the "user" header -/// until the first blank line. -pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option { - let header_idx = find_nth_last_user_header_index(lines, n)?; - extract_message_text_after_header(lines, header_idx) -} - -/// Extract message text starting after `header_idx` until the first blank line. -fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option { - let start = header_idx + 1; - let mut out: Vec = Vec::new(); - for line in lines.iter().skip(start) { - let is_blank = line - .spans - .iter() - .all(|s| s.content.as_ref().trim().is_empty()); - if is_blank { - break; - } - let text: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - out.push(text); - } - if out.is_empty() { - None - } else { - Some(out.join("\n")) - } -} - -/// Given a header index, return the inclusive range for the message block -/// [header_idx, end) where end is the first blank line after the header or the -/// end of the transcript. -fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) { - let mut end = header_idx + 1; - while end < lines.len() { - let is_blank = lines[end] - .spans - .iter() - .all(|s| s.content.as_ref().trim().is_empty()); - if is_blank { - break; - } - end += 1; - } - (header_idx, end) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line(s: &str) -> Line<'static> { - s.to_string().into() - } - - fn transcript_with_users(count: usize) -> Vec> { - // Build a transcript with `count` user messages, each followed by one body line and a blank line. - let mut v = Vec::new(); - for i in 0..count { - v.push(line("user")); - v.push(line(&format!("message {i}"))); - v.push(line("")); - } - v - } - - #[test] - fn normalize_wraps_to_one_when_past_oldest() { - let lines = transcript_with_users(2); - assert_eq!(normalize_backtrack_n(&lines, 1), 1); - assert_eq!(normalize_backtrack_n(&lines, 2), 2); - // Requesting 3rd when only 2 exist wraps to 1 - assert_eq!(normalize_backtrack_n(&lines, 3), 1); - } - - #[test] - fn normalize_returns_zero_when_no_user_messages() { - let lines = transcript_with_users(0); - assert_eq!(normalize_backtrack_n(&lines, 1), 0); - assert_eq!(normalize_backtrack_n(&lines, 5), 0); - } - - #[test] - fn normalize_keeps_valid_n() { - let lines = transcript_with_users(3); - assert_eq!(normalize_backtrack_n(&lines, 2), 2); - } -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9cdd29dd02..ad3d5a9030 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -44,6 +44,7 @@ use ratatui::style::Stylize; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use std::any::Any; use std::collections::HashMap; use std::io::Cursor; use std::path::Path; @@ -70,7 +71,7 @@ pub(crate) enum PatchEventType { /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { +pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn display_lines(&self, width: u16) -> Vec>; fn transcript_lines(&self) -> Vec> { @@ -90,9 +91,15 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { } } +impl dyn HistoryCell { + pub(crate) fn as_any(&self) -> &dyn Any { + self + } +} + #[derive(Debug)] pub(crate) struct UserHistoryCell { - message: String, + pub message: String, } impl HistoryCell for UserHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 04ead75058..308e563ded 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -34,7 +34,6 @@ mod app_backtrack; mod app_event; mod app_event_sender; mod ascii_animation; -mod backtrack_helpers; mod bottom_pane; mod chatwidget; mod citation_regex; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 0a7e54f06f..e5524b252b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1,6 +1,8 @@ use std::io::Result; +use std::sync::Arc; use std::time::Duration; +use crate::history_cell::HistoryCell; use crate::render::line_utils::push_owned_lines; use crate::tui; use crate::tui::TuiEvent; @@ -15,6 +17,7 @@ use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; @@ -24,8 +27,8 @@ pub(crate) enum Overlay { } impl Overlay { - pub(crate) fn new_transcript(lines: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(lines)) + pub(crate) fn new_transcript(cells: Vec>) -> Self { + Self::Transcript(TranscriptOverlay::new(cells)) } pub(crate) fn new_static_with_title(lines: Vec>, title: String) -> Self { @@ -73,21 +76,24 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) { /// Generic widget for rendering a pager view. struct PagerView { - lines: Vec>, + texts: Vec>, scroll_offset: usize, title: String, wrap_cache: Option, last_content_height: Option, + /// If set, on next render ensure this chunk is visible. + pending_scroll_chunk: Option, } impl PagerView { - fn new(lines: Vec>, title: String, scroll_offset: usize) -> Self { + fn new(texts: Vec>, title: String, scroll_offset: usize) -> Self { Self { - lines, + texts, scroll_offset, title, wrap_cache: None, last_content_height: None, + pending_scroll_chunk: None, } } @@ -96,6 +102,14 @@ impl PagerView { let content_area = self.scroll_area(area); self.update_last_content_height(content_area.height); self.ensure_wrapped(content_area.width); + // If there is a pending request to scroll a specific chunk into view, + // satisfy it now that wrapping is up to date for this width. + if let (Some(idx), Some(cache)) = + (self.pending_scroll_chunk.take(), self.wrap_cache.as_ref()) + && let Some(range) = cache.chunk_ranges.get(idx).cloned() + { + self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len()); + } // Compute page bounds without holding an immutable borrow on cache while mutating self let wrapped_len = self .wrap_cache @@ -108,40 +122,12 @@ impl PagerView { let start = self.scroll_offset; let end = (start + content_area.height as usize).min(wrapped_len); - let (wrapped, _src_idx) = self.cached(); + let wrapped = self.cached(); let page = &wrapped[start..end]; self.render_content_page_prepared(content_area, buf, page); self.render_bottom_bar(area, content_area, buf, wrapped); } - fn render_with_highlight( - &mut self, - area: Rect, - buf: &mut Buffer, - highlight: Option<(usize, usize)>, - ) { - self.render_header(area, buf); - let content_area = self.scroll_area(area); - self.update_last_content_height(content_area.height); - self.ensure_wrapped(content_area.width); - // Compute page bounds first to avoid borrow conflicts - let wrapped_len = self - .wrap_cache - .as_ref() - .map(|c| c.wrapped.len()) - .unwrap_or(0); - self.scroll_offset = self - .scroll_offset - .min(wrapped_len.saturating_sub(content_area.height as usize)); - let start = self.scroll_offset; - let end = (start + content_area.height as usize).min(wrapped_len); - - let (wrapped, src_idx) = self.cached(); - let page = self.page_with_optional_highlight(wrapped, src_idx, start, end, highlight); - self.render_content_page_prepared(content_area, buf, &page); - self.render_bottom_bar(area, content_area, buf, wrapped); - } - fn render_header(&self, area: Rect, buf: &mut Buffer) { Span::from("/ ".repeat(area.width as usize / 2)) .dim() @@ -270,7 +256,8 @@ impl PagerView { struct WrapCache { width: u16, wrapped: Vec>, - src_idx: Vec, + /// For each input Text chunk, the inclusive-excluded range of wrapped lines produced. + chunk_ranges: Vec>, base_len: usize, } @@ -278,72 +265,37 @@ impl PagerView { fn ensure_wrapped(&mut self, width: u16) { let width = width.max(1); let needs = match self.wrap_cache { - Some(ref c) => c.width != width || c.base_len != self.lines.len(), + Some(ref c) => c.width != width || c.base_len != self.texts.len(), None => true, }; if !needs { return; } let mut wrapped: Vec> = Vec::new(); - let mut src_idx: Vec = Vec::new(); - for (i, line) in self.lines.iter().enumerate() { - let ws = crate::wrapping::word_wrap_line(line, width as usize); - src_idx.extend(std::iter::repeat_n(i, ws.len())); - push_owned_lines(&ws, &mut wrapped); + let mut chunk_ranges: Vec> = Vec::with_capacity(self.texts.len()); + for text in &self.texts { + let start = wrapped.len(); + for line in &text.lines { + let ws = crate::wrapping::word_wrap_line(line, width as usize); + push_owned_lines(&ws, &mut wrapped); + } + let end = wrapped.len(); + chunk_ranges.push(start..end); } self.wrap_cache = Some(WrapCache { width, wrapped, - src_idx, - base_len: self.lines.len(), + chunk_ranges, + base_len: self.texts.len(), }); } - fn cached(&self) -> (&[Line<'static>], &[usize]) { + fn cached(&self) -> &[Line<'static>] { if let Some(cache) = self.wrap_cache.as_ref() { - (&cache.wrapped, &cache.src_idx) + &cache.wrapped } else { - (&[], &[]) - } - } - - fn page_with_optional_highlight<'a>( - &self, - wrapped: &'a [Line<'static>], - src_idx: &[usize], - start: usize, - end: usize, - highlight: Option<(usize, usize)>, - ) -> std::borrow::Cow<'a, [Line<'static>]> { - use ratatui::style::Modifier; - let (hi_start, hi_end) = match highlight { - Some(r) => r, - None => return std::borrow::Cow::Borrowed(&wrapped[start..end]), - }; - let mut out: Vec> = Vec::with_capacity(end - start); - let mut bold_done = false; - for (row, src_line) in wrapped - .iter() - .enumerate() - .skip(start) - .take(end.saturating_sub(start)) - { - let mut line = src_line.clone(); - if let Some(src) = src_idx.get(row).copied() - && src >= hi_start - && src < hi_end - { - for (i, s) in line.spans.iter_mut().enumerate() { - s.style.add_modifier |= Modifier::REVERSED; - if !bold_done && i == 0 { - s.style.add_modifier |= Modifier::BOLD; - bold_done = true; - } - } - } - out.push(line); + &[] } - std::borrow::Cow::Owned(out) } fn is_scrolled_to_bottom(&self) -> bool { @@ -363,38 +315,108 @@ impl PagerView { let max_scroll = cache.wrapped.len().saturating_sub(visible); self.scroll_offset >= max_scroll } + + /// Request that the given text chunk index be scrolled into view on next render. + fn scroll_chunk_into_view(&mut self, chunk_index: usize) { + self.pending_scroll_chunk = Some(chunk_index); + } + + fn ensure_range_visible( + &mut self, + range: std::ops::Range, + viewport_height: usize, + total_wrapped: usize, + ) { + if viewport_height == 0 || total_wrapped == 0 { + return; + } + let first = range.start.min(total_wrapped.saturating_sub(1)); + let last = range + .end + .saturating_sub(1) + .min(total_wrapped.saturating_sub(1)); + let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1)); + let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1)); + + if first < current_top { + self.scroll_offset = first; + } else if last > current_bottom { + // Scroll just enough so that 'last' is visible at the bottom + self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1)); + } + } } pub(crate) struct TranscriptOverlay { view: PagerView, - highlight_range: Option<(usize, usize)>, + cells: Vec>, + highlight_cell: Option, is_done: bool, } impl TranscriptOverlay { - pub(crate) fn new(transcript_lines: Vec>) -> Self { + pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( - transcript_lines, + Self::render_cells_to_texts(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, ), - highlight_range: None, + cells: transcript_cells, + highlight_cell: None, is_done: false, } } - pub(crate) fn insert_lines(&mut self, lines: Vec>) { + fn render_cells_to_texts( + cells: &[Arc], + highlight_cell: Option, + ) -> Vec> { + let mut texts: Vec> = Vec::new(); + let mut first = true; + for (idx, cell) in cells.iter().enumerate() { + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !first { + lines.push(Line::from("")); + } + let cell_lines = if Some(idx) == highlight_cell { + cell.transcript_lines() + .into_iter() + .map(|l| l.reversed()) + .collect() + } else { + cell.transcript_lines() + }; + lines.extend(cell_lines); + texts.push(Text::from(lines)); + first = false; + } + texts + } + + pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); - self.view.lines.extend(lines); + // Append as a new Text chunk (with a separating blank if needed) + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !self.cells.is_empty() { + lines.push(Line::from("")); + } + lines.extend(cell.transcript_lines()); + self.view.texts.push(Text::from(lines)); + self.cells.push(cell); self.view.wrap_cache = None; if follow_bottom { self.view.scroll_offset = usize::MAX; } } - pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) { - self.highlight_range = range; + pub(crate) fn set_highlight_cell(&mut self, cell: Option) { + self.highlight_cell = cell; + self.view.wrap_cache = None; + self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell); + if let Some(idx) = self.highlight_cell { + self.view.scroll_chunk_into_view(idx); + } } fn render_hints(&self, area: Rect, buf: &mut Buffer) { @@ -402,9 +424,7 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")]; - if let Some((start, end)) = self.highlight_range - && end > start - { + if self.highlight_cell.is_some() { pairs.push(("⏎", "edit message")); } render_key_hints(line2, buf, &pairs); @@ -414,8 +434,7 @@ impl TranscriptOverlay { let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); - self.view - .render_with_highlight(top, buf, self.highlight_range); + self.view.render(top, buf); self.render_hints(bottom, buf); } } @@ -458,9 +477,6 @@ impl TranscriptOverlay { pub(crate) fn is_done(&self) -> bool { self.is_done } - pub(crate) fn set_scroll_offset(&mut self, offset: usize) { - self.view.scroll_offset = offset; - } } pub(crate) struct StaticOverlay { @@ -471,7 +487,7 @@ pub(crate) struct StaticOverlay { impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { Self { - view: PagerView::new(lines, title, 0), + view: PagerView::new(vec![Text::from(lines)], title, 0), is_done: false, } } @@ -534,9 +550,26 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; + #[derive(Debug)] + struct TestCell { + lines: Vec>, + } + + impl crate::history_cell::HistoryCell for TestCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } + } + #[test] fn edit_prev_hint_is_visible() { - let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]); + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); // Render into a small buffer and assert the backtrack hint is present let area = Rect::new(0, 0, 40, 10); @@ -561,9 +594,15 @@ mod tests { fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines let mut overlay = TranscriptOverlay::new(vec![ - Line::from("alpha"), - Line::from("beta"), - Line::from("gamma"), + Arc::new(TestCell { + lines: vec![Line::from("alpha")], + }), + Arc::new(TestCell { + lines: vec![Line::from("beta")], + }), + Arc::new(TestCell { + lines: vec![Line::from("gamma")], + }), ]); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -573,8 +612,15 @@ mod tests { #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { - let mut overlay = - TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect()); + let mut overlay = TranscriptOverlay::new( + (0..20) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); @@ -584,22 +630,33 @@ mod tests { "expected initial render to leave view at bottom" ); - overlay.insert_lines(vec!["tail".into()]); + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["tail".into()], + })); assert_eq!(overlay.view.scroll_offset, usize::MAX); } #[test] fn transcript_overlay_preserves_manual_scroll_position() { - let mut overlay = - TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect()); + let mut overlay = TranscriptOverlay::new( + (0..20) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); overlay.view.scroll_offset = 0; - overlay.insert_lines(vec!["tail".into()]); + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["tail".into()], + })); assert_eq!(overlay.view.scroll_offset, 0); } @@ -620,17 +677,21 @@ 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![long.into(), long.into()], "T".to_string(), 0); + let mut pv = PagerView::new( + vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])], + "T".to_string(), + 0, + ); // Build cache at width 24 pv.ensure_wrapped(24); - let (w1, _) = pv.cached(); + let w1 = pv.cached(); assert!(!w1.is_empty(), "expected wrapped output to be non-empty"); let ptr1 = w1.as_ptr(); // Re-run with same width: cache should be reused (pointer stability heuristic) pv.ensure_wrapped(24); - let (w2, _) = pv.cached(); + let w2 = pv.cached(); let ptr2 = w2.as_ptr(); assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width"); @@ -638,7 +699,7 @@ mod tests { // Drop immutable borrow before mutating let prev_len = w2.len(); pv.ensure_wrapped(36); - let (w3, _) = pv.cached(); + let w3 = pv.cached(); assert_ne!( prev_len, w3.len(), @@ -649,15 +710,16 @@ 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![long.into()], "T".to_string(), 0); + let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0); pv.ensure_wrapped(28); - let (w1, _) = pv.cached(); + let w1 = pv.cached(); let len1 = w1.len(); // Append new lines should cause ensure_wrapped to rebuild due to len change - pv.lines.extend([long.into(), long.into()]); + pv.texts.push(Text::from(vec![long.into()])); + pv.texts.push(Text::from(vec![long.into()])); pv.ensure_wrapped(28); - let (w2, _) = pv.cached(); + let w2 = pv.cached(); assert!( w2.len() >= len1, "wrapped length should grow or stay same after append" diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index b9105e8021..0c03edef47 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -4,10 +4,10 @@ expression: term.backend() --- "/ T R A N S C R I P T / / / / / / / / / " "alpha " +" " "beta " +" " "gamma " -"~ " -"~ " "───────────────────────────────── 100% ─" " ↑/↓ scroll PgUp/PgDn page Home/End " " q quit Esc edit prev " From 62258df92fd1a6165c0497591cc1281f36212939 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Thu, 18 Sep 2025 14:14:16 -0700 Subject: [PATCH 14/42] feat: /review (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `/review` action in TUI Screenshot 2025-09-17 at 12 41 19 AM --- codex-rs/core/src/codex.rs | 4 +- codex-rs/core/src/review_format.rs | 10 +- .../src/event_processor_with_human_output.rs | 3 + codex-rs/protocol/src/protocol.rs | 1 + codex-rs/tui/src/chatwidget.rs | 101 +++++++++++++++--- codex-rs/tui/src/chatwidget/tests.rs | 81 ++++++++++++++ codex-rs/tui/src/history_cell.rs | 7 ++ codex-rs/tui/src/slash_command.rs | 3 + 8 files changed, 189 insertions(+), 21 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1de37f6f6a..8caa2e8ee0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3303,7 +3303,7 @@ async fn exit_review_mode( {findings_str} - + "#)); } else { user_message.push_str(r#" @@ -3312,7 +3312,7 @@ async fn exit_review_mode( None. - + "#); } diff --git a/codex-rs/core/src/review_format.rs b/codex-rs/core/src/review_format.rs index 272010d564..6a64d06fa1 100644 --- a/codex-rs/core/src/review_format.rs +++ b/codex-rs/core/src/review_format.rs @@ -22,14 +22,14 @@ pub fn format_review_findings_block( selection: Option<&[bool]>, ) -> String { let mut lines: Vec = Vec::new(); + lines.push(String::new()); // Header - let header = if findings.len() > 1 { - "Full review comments:" + if findings.len() > 1 { + lines.push("Full review comments:".to_string()); } else { - "Review comment:" - }; - lines.push(header.to_string()); + lines.push("Review comment:".to_string()); + } for (idx, item) in findings.iter().enumerate() { lines.push(String::new()); 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 c126208fad..5ef7d51a26 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -558,6 +558,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { TurnAbortReason::Replaced => { ts_println!(self, "task aborted: replaced by a new task"); } + TurnAbortReason::ReviewEnded => { + ts_println!(self, "task aborted: review ended"); + } }, EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::ConversationPath(_) => {} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dddaeee23f..8009ac9bf3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1240,6 +1240,7 @@ pub struct TurnAbortedEvent { pub enum TurnAbortReason { Interrupted, Replaced, + ReviewEnded, } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7ab954f263..e024fd0fab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -19,6 +19,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::InputItem; use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; @@ -27,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; @@ -36,6 +38,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -63,10 +66,12 @@ use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; use crate::history_cell; +use crate::history_cell::AgentMessageCell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; @@ -91,7 +96,6 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; -use codex_protocol::mcp_protocol::ConversationId; // Track information about an in-flight exec command. struct RunningCommand { @@ -141,6 +145,8 @@ pub(crate) struct ChatWidget { queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, + // Simple review mode flag; used to adjust layout and banners. + is_review_mode: bool, } struct UserMessage { @@ -279,13 +285,10 @@ impl ChatWidget { self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } - /// Finalize any active exec as failed, push an error message into history, - /// and stop/clear running UI state. - fn finalize_turn_with_error_message(&mut self, message: String) { + /// Finalize any active exec as failed and stop/clear running UI state. + fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_exec_cell_as_failed(); - // Emit the provided error message/history cell. - self.add_to_history(history_cell::new_error_event(message)); // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); @@ -293,7 +296,8 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { - self.finalize_turn_with_error_message(message); + self.finalize_turn(); + self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -303,11 +307,15 @@ impl ChatWidget { /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self) { + fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn_with_error_message( - "Conversation interrupted - tell the model what to do differently".to_owned(), - ); + self.finalize_turn(); + + if reason != TurnAbortReason::ReviewEnded { + self.add_to_history(history_cell::new_error_event( + "Conversation interrupted - tell the model what to do differently".to_owned(), + )); + } // If any messages were queued during the task, restore them into the composer. if !self.queued_user_messages.is_empty() { @@ -702,6 +710,7 @@ impl ChatWidget { show_welcome_banner: true, suppress_session_configured_redraw: false, pending_notification: None, + is_review_mode: false, } } @@ -758,6 +767,7 @@ impl ChatWidget { show_welcome_banner: true, suppress_session_configured_redraw: true, pending_notification: None, + is_review_mode: false, } } @@ -872,6 +882,15 @@ impl ChatWidget { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } + SlashCommand::Review => { + // Simplified flow: directly send a review op for current changes. + self.submit_op(Op::Review { + review_request: ReviewRequest { + prompt: "review current changes".to_string(), + user_facing_hint: "current changes".to_string(), + }, + }); + } SlashCommand::Model => { self.open_model_popup(); } @@ -1091,11 +1110,14 @@ impl ChatWidget { EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { - self.on_interrupted_turn(); + self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { self.on_error("Turn aborted: replaced by a new task".to_owned()) } + TurnAbortReason::ReviewEnded => { + self.on_interrupted_turn(ev.reason); + } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { @@ -1132,9 +1154,60 @@ impl ChatWidget { self.app_event_tx .send(crate::app_event::AppEvent::ConversationHistory(ev)); } - EventMsg::EnteredReviewMode(_) => {} - EventMsg::ExitedReviewMode(_) => {} + EventMsg::EnteredReviewMode(review_request) => { + self.on_entered_review_mode(review_request) + } + EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), + } + } + + fn on_entered_review_mode(&mut self, review: ReviewRequest) { + // Enter review mode and emit a concise banner + self.is_review_mode = true; + let banner = format!(">> Code review started: {} <<", review.user_facing_hint); + self.add_to_history(history_cell::new_review_status_line(banner)); + self.request_redraw(); + } + + fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { + // Leave review mode; if output is present, flush pending stream + show results. + if let Some(output) = review.review_output { + self.flush_answer_stream_with_separator(); + self.flush_interrupt_queue(); + self.flush_active_exec_cell(); + + if output.findings.is_empty() { + let explanation = output.overall_explanation.trim().to_string(); + if explanation.is_empty() { + tracing::error!("Reviewer failed to output a response."); + self.add_to_history(history_cell::new_error_event( + "Reviewer failed to output a response.".to_owned(), + )); + } else { + // Show explanation when there are no structured findings. + let mut rendered: Vec> = vec!["".into()]; + append_markdown(&explanation, &mut rendered, &self.config); + let body_cell = AgentMessageCell::new(rendered, false); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } else { + let message_text = + codex_core::review_format::format_review_findings_block(&output.findings, None); + let mut message_lines: Vec> = Vec::new(); + append_markdown(&message_text, &mut message_lines, &self.config); + let body_cell = AgentMessageCell::new(message_lines, true); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } } + + self.is_review_mode = false; + // Append a finishing banner at the end of this turn. + self.add_to_history(history_cell::new_review_status_line( + "<< Code review finished >>".to_string(), + )); + self.request_redraw(); } fn on_user_message_event(&mut self, event: UserMessageEvent) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fb4ea5e888..ffe3f3f707 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -19,10 +19,17 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::FileChange; use codex_core::protocol::InputMessageKind; +use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::ReviewCodeLocation; +use codex_core::protocol::ReviewFinding; +use codex_core::protocol::ReviewLineRange; +use codex_core::protocol::ReviewOutputEvent; +use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; @@ -184,6 +191,79 @@ fn resumed_initial_messages_render_history() { ); } +/// Entering review mode uses the hint provided by the review request. +#[test] +fn entered_review_mode_uses_request_hint() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + prompt: "Review the latest changes".to_string(), + user_facing_hint: "feature branch".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: feature branch <<\n"); + assert!(chat.is_review_mode); +} + +/// Entering review mode renders the current changes banner when requested. +#[test] +fn entered_review_mode_defaults_to_current_changes_banner() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + prompt: "Review the current changes".to_string(), + user_facing_hint: "current changes".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: current changes <<\n"); + assert!(chat.is_review_mode); +} + +/// Completing review with findings shows the selection popup and finishes with +/// the closing banner while clearing review mode state. +#[test] +fn exited_review_mode_emits_results_and_finishes() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + let review = ReviewOutputEvent { + findings: vec![ReviewFinding { + title: "[P1] Fix bug".to_string(), + body: "Something went wrong".to_string(), + confidence_score: 0.9, + priority: 1, + code_location: ReviewCodeLocation { + absolute_file_path: PathBuf::from("src/lib.rs"), + line_range: ReviewLineRange { start: 10, end: 12 }, + }, + }], + overall_correctness: "needs work".to_string(), + overall_explanation: "Investigate the failure".to_string(), + overall_confidence_score: 0.5, + }; + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: Some(review), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("finished banner")); + assert_eq!(banner, "\n<< Code review finished >>\n"); + assert!(!chat.is_review_mode); +} + #[cfg_attr( target_os = "macos", ignore = "system configuration APIs are blocked under macOS seatbelt" @@ -252,6 +332,7 @@ fn make_chatwidget_manual() -> ( queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, pending_notification: None, + is_review_mode: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index ad3d5a9030..a99a4dd836 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -236,6 +236,13 @@ impl HistoryCell for TranscriptOnlyHistoryCell { } } +/// Cyan history cell line showing the current review status. +pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![Line::from(message.cyan())], + } +} + #[derive(Debug)] pub(crate) struct PatchHistoryCell { event_type: PatchEventType, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3268a92a2d..433c0a6d7f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Review, New, Init, Compact, @@ -34,6 +35,7 @@ impl SlashCommand { SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", + SlashCommand::Review => "review my current changes and find issues", SlashCommand::Quit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", @@ -61,6 +63,7 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention From 8595237505a1e0faabc2af3db805b66ce3ae182d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 18 Sep 2025 14:37:06 -0700 Subject: [PATCH 15/42] fix: ensure cwd for conversation and sandbox are separate concerns (#3874) Previous to this PR, both of these functions take a single `cwd`: https://github.com/openai/codex/blob/71038381aa0f51aa62e1a2bcc7cbf26a05b141f3/codex-rs/core/src/seatbelt.rs#L19-L25 https://github.com/openai/codex/blob/71038381aa0f51aa62e1a2bcc7cbf26a05b141f3/codex-rs/core/src/landlock.rs#L16-L23 whereas `cwd` and `sandbox_cwd` should be set independently (fixed in this PR). Added `sandbox_distinguishes_command_and_policy_cwds()` to `codex-rs/exec/tests/suite/sandbox.rs` to verify this. --- codex-rs/cli/src/debug_sandbox.rs | 24 +++- codex-rs/core/src/codex.rs | 5 + codex-rs/core/src/exec.rs | 18 ++- codex-rs/core/src/landlock.rs | 14 ++- codex-rs/core/src/seatbelt.rs | 11 +- codex-rs/core/src/shell.rs | 2 + codex-rs/core/tests/suite/exec.rs | 2 +- .../core/tests/suite/exec_stream_events.rs | 37 ++++-- codex-rs/core/tests/suite/seatbelt.rs | 10 +- codex-rs/exec/tests/suite/sandbox.rs | 113 +++++++++++++++++- .../linux-sandbox/tests/suite/landlock.rs | 7 +- .../mcp-server/src/codex_message_processor.rs | 2 + 12 files changed, 209 insertions(+), 36 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6fe7f003c7..a7d7103c2f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -64,7 +64,6 @@ async fn run_command_under_sandbox( sandbox_type: SandboxType, ) -> anyhow::Result<()> { let sandbox_mode = create_sandbox_mode(full_auto); - let cwd = std::env::current_dir()?; let config = Config::load_with_cli_overrides( config_overrides .parse_overrides() @@ -75,13 +74,29 @@ async fn run_command_under_sandbox( ..Default::default() }, )?; + + // In practice, this should be `std::env::current_dir()` because this CLI + // does not support `--cwd`, but let's use the config value for consistency. + let cwd = config.cwd.clone(); + // For now, we always use the same cwd for both the command and the + // sandbox policy. In the future, we could add a CLI option to set them + // separately. + let sandbox_policy_cwd = cwd.clone(); + let stdio_policy = StdioPolicy::Inherit; let env = create_env(&config.shell_environment_policy); let mut child = match sandbox_type { SandboxType::Seatbelt => { - spawn_command_under_seatbelt(command, &config.sandbox_policy, cwd, stdio_policy, env) - .await? + spawn_command_under_seatbelt( + command, + cwd, + &config.sandbox_policy, + sandbox_policy_cwd.as_path(), + stdio_policy, + env, + ) + .await? } SandboxType::Landlock => { #[expect(clippy::expect_used)] @@ -91,8 +106,9 @@ async fn run_command_under_sandbox( spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, - &config.sandbox_policy, cwd, + &config.sandbox_policy, + sandbox_policy_cwd.as_path(), stdio_policy, env, ) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8caa2e8ee0..e9f52b1c94 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -898,6 +899,7 @@ impl Session { exec_args.params, exec_args.sandbox_type, exec_args.sandbox_policy, + exec_args.sandbox_cwd, exec_args.codex_linux_sandbox_exe, exec_args.stdout_stream, ) @@ -2691,6 +2693,7 @@ pub struct ExecInvokeArgs<'a> { pub params: ExecParams, pub sandbox_type: SandboxType, pub sandbox_policy: &'a SandboxPolicy, + pub sandbox_cwd: &'a Path, pub codex_linux_sandbox_exe: &'a Option, pub stdout_stream: Option, } @@ -2882,6 +2885,7 @@ async fn handle_container_exec_with_params( params: params.clone(), sandbox_type, sandbox_policy: &turn_context.sandbox_policy, + sandbox_cwd: &turn_context.cwd, codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None @@ -3016,6 +3020,7 @@ async fn handle_sandbox_error( params, sandbox_type: SandboxType::None, sandbox_policy: &turn_context.sandbox_policy, + sandbox_cwd: &turn_context.cwd, codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 8fecb16fc3..9e11604c39 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -3,6 +3,7 @@ use std::os::unix::process::ExitStatusExt; use std::collections::HashMap; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; use std::time::Duration; @@ -82,6 +83,7 @@ pub async fn process_exec_tool_call( params: ExecParams, sandbox_type: SandboxType, sandbox_policy: &SandboxPolicy, + sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, stdout_stream: Option, ) -> Result { @@ -94,12 +96,16 @@ pub async fn process_exec_tool_call( SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await, SandboxType::MacosSeatbelt => { let ExecParams { - command, cwd, env, .. + command, + cwd: command_cwd, + env, + .. } = params; let child = spawn_command_under_seatbelt( command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, StdioPolicy::RedirectForShellTool, env, ) @@ -108,7 +114,10 @@ pub async fn process_exec_tool_call( } SandboxType::LinuxSeccomp => { let ExecParams { - command, cwd, env, .. + command, + cwd: command_cwd, + env, + .. } = params; let codex_linux_sandbox_exe = codex_linux_sandbox_exe @@ -117,8 +126,9 @@ pub async fn process_exec_tool_call( let child = spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, StdioPolicy::RedirectForShellTool, env, ) diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 8c8840c269..264ea747ca 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -16,21 +16,22 @@ use tokio::process::Child; pub async fn spawn_command_under_linux_sandbox

( codex_linux_sandbox_exe: P, command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result where P: AsRef, { - let args = create_linux_sandbox_command_args(command, sandbox_policy, &cwd); + let args = create_linux_sandbox_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = Some("codex-linux-sandbox"); spawn_child_async( codex_linux_sandbox_exe.as_ref().to_path_buf(), args, arg0, - cwd, + command_cwd, sandbox_policy, stdio_policy, env, @@ -42,10 +43,13 @@ where fn create_linux_sandbox_command_args( command: Vec, sandbox_policy: &SandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, ) -> Vec { #[expect(clippy::expect_used)] - let sandbox_policy_cwd = cwd.to_str().expect("cwd must be valid UTF-8").to_string(); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .expect("cwd must be valid UTF-8") + .to_string(); #[expect(clippy::expect_used)] let sandbox_policy_json = diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 285e5d5691..09e93668bc 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -18,19 +18,20 @@ const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; pub async fn spawn_command_under_seatbelt( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args(command, sandbox_policy, &cwd); + let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); spawn_child_async( PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), args, arg0, - cwd, + command_cwd, sandbox_policy, stdio_policy, env, @@ -41,7 +42,7 @@ pub async fn spawn_command_under_seatbelt( fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, ) -> Vec { let (file_write_policy, extra_cli_args) = { if sandbox_policy.has_full_disk_write_access() { @@ -51,7 +52,7 @@ fn create_seatbelt_command_args( Vec::::new(), ) } else { - let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); let mut writable_folder_policies: Vec = Vec::new(); let mut cli_args: Vec = Vec::new(); diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 28cf61dd94..3bfef7e1e0 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -349,6 +349,7 @@ mod tests { }, SandboxType::None, &SandboxPolicy::DangerFullAccess, + temp_home.path(), &None, None, ) @@ -455,6 +456,7 @@ mod macos_tests { }, SandboxType::None, &SandboxPolicy::DangerFullAccess, + temp_home.path(), &None, None, ) diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 9e0cffe67f..280917b530 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -39,7 +39,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result&2".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(5_000), env: HashMap::new(), with_escalated_permissions: None, @@ -114,6 +117,7 @@ async fn test_exec_stderr_stream_events_echo() { params, SandboxType::None, &policy, + cwd.as_path(), &None, Some(stdout_stream), ) @@ -152,9 +156,10 @@ async fn test_aggregated_output_interleaves_in_order() { "printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(5_000), env: HashMap::new(), with_escalated_permissions: None, @@ -163,9 +168,16 @@ async fn test_aggregated_output_interleaves_in_order() { let policy = SandboxPolicy::new_read_only_policy(); - let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None) - .await - .expect("process_exec_tool_call"); + let result = process_exec_tool_call( + params, + SandboxType::None, + &policy, + cwd.as_path(), + &None, + None, + ) + .await + .expect("process_exec_tool_call"); assert_eq!(result.exit_code, 0); assert_eq!(result.stdout.text, "O1\nO2\n"); @@ -182,9 +194,10 @@ async fn test_exec_timeout_returns_partial_output() { "printf 'before\\n'; sleep 2; printf 'after\\n'".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(200), env: HashMap::new(), with_escalated_permissions: None, @@ -193,7 +206,15 @@ async fn test_exec_timeout_returns_partial_output() { let policy = SandboxPolicy::new_read_only_policy(); - let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None).await; + let result = process_exec_tool_call( + params, + SandboxType::None, + &policy, + cwd.as_path(), + &None, + None, + ) + .await; let Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) = result else { panic!("expected timeout error"); diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 69f1d8907e..78f599d42e 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -171,6 +171,8 @@ async fn python_getpwuid_works_under_seatbelt() { // ReadOnly is sufficient here since we are only exercising user lookup. let policy = SandboxPolicy::ReadOnly; + let command_cwd = std::env::current_dir().expect("getcwd"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_seatbelt( vec![ @@ -179,8 +181,9 @@ async fn python_getpwuid_works_under_seatbelt() { // Print the passwd struct; success implies lookup worked. "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), ], + command_cwd, &policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, HashMap::new(), ) @@ -216,13 +219,16 @@ fn create_test_scenario(tmp: &TempDir) -> TestScenario { /// Note that `path` must be absolute. async fn touch(path: &Path, policy: &SandboxPolicy) -> bool { assert!(path.is_absolute(), "Path must be absolute: {path:?}"); + let command_cwd = std::env::current_dir().expect("getcwd"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_seatbelt( vec![ "/usr/bin/touch".to_string(), path.to_string_lossy().to_string(), ], + command_cwd, policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, HashMap::new(), ) diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index a98e0a9760..5355a0b2eb 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -4,27 +4,39 @@ use codex_core::spawn::StdioPolicy; use std::collections::HashMap; use std::future::Future; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; +use tokio::fs::create_dir_all; use tokio::process::Child; #[cfg(target_os = "macos")] async fn spawn_command_under_sandbox( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { use codex_core::seatbelt::spawn_command_under_seatbelt; - spawn_command_under_seatbelt(command, sandbox_policy, cwd, stdio_policy, env).await + spawn_command_under_seatbelt( + command, + command_cwd, + sandbox_policy, + sandbox_cwd, + stdio_policy, + env, + ) + .await } #[cfg(target_os = "linux")] async fn spawn_command_under_sandbox( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { @@ -33,8 +45,9 @@ async fn spawn_command_under_sandbox( spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, stdio_policy, env, ) @@ -74,14 +87,17 @@ if __name__ == '__main__': p.join() "#; + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_sandbox( vec![ "python3".to_string(), "-c".to_string(), python_code.to_string(), ], + command_cwd, &policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::Inherit, HashMap::new(), ) @@ -92,6 +108,88 @@ if __name__ == '__main__': assert!(status.success(), "python exited with {status:?}"); } +#[tokio::test] +async fn sandbox_distinguishes_command_and_policy_cwds() { + let temp = tempfile::tempdir().expect("should be able to create temp dir"); + let sandbox_root = temp.path().join("sandbox"); + let command_root = temp.path().join("command"); + create_dir_all(&sandbox_root).await.expect("mkdir"); + create_dir_all(&command_root).await.expect("mkdir"); + let canonical_sandbox_root = tokio::fs::canonicalize(&sandbox_root) + .await + .expect("canonicalize sandbox root"); + let canonical_allowed_path = canonical_sandbox_root.join("allowed.txt"); + + let disallowed_path = command_root.join("forbidden.txt"); + + // Note writable_roots is empty: verify that `canonical_allowed_path` is + // writable only because it is under the sandbox policy cwd, not because it + // is under a writable root. + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + // Attempt to write inside the command cwd, which is outside of the sandbox policy cwd. + let mut child = spawn_command_under_sandbox( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo forbidden > forbidden.txt".to_string(), + ], + command_root.clone(), + &policy, + canonical_sandbox_root.as_path(), + StdioPolicy::Inherit, + HashMap::new(), + ) + .await + .expect("should spawn command writing to forbidden path"); + + let status = child + .wait() + .await + .expect("should wait for forbidden command"); + assert!( + !status.success(), + "sandbox unexpectedly allowed writing to command cwd: {status:?}" + ); + let forbidden_exists = tokio::fs::try_exists(&disallowed_path) + .await + .expect("try_exists failed"); + assert!( + !forbidden_exists, + "forbidden path should not have been created" + ); + + // Writing to the sandbox policy cwd after changing directories into it should succeed. + let mut child = spawn_command_under_sandbox( + vec![ + "/usr/bin/touch".to_string(), + canonical_allowed_path.to_string_lossy().into_owned(), + ], + command_root, + &policy, + canonical_sandbox_root.as_path(), + StdioPolicy::Inherit, + HashMap::new(), + ) + .await + .expect("should spawn command writing to sandbox root"); + + let status = child.wait().await.expect("should wait for allowed command"); + assert!( + status.success(), + "sandbox blocked allowed write: {status:?}" + ); + let allowed_exists = tokio::fs::try_exists(&canonical_allowed_path) + .await + .expect("try_exists allowed failed"); + assert!(allowed_exists, "allowed path should exist"); +} + fn unix_sock_body() { unsafe { let mut fds = [0i32; 2]; @@ -200,10 +298,13 @@ where cmds.push(test_selector.into()); // Your existing launcher: + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_sandbox( cmds, + command_cwd, policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), stdio_policy, HashMap::from([("IN_SANDBOX".into(), "1".into())]), ) diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index fddb43c38e..ea4930fada 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -35,9 +35,11 @@ fn create_env_from_core_vars() -> HashMap { #[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)] async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { + let cwd = std::env::current_dir().expect("cwd should exist"); + let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().map(|elm| elm.to_string()).collect(), - cwd: std::env::current_dir().expect("cwd should exist"), + cwd, timeout_ms: Some(timeout_ms), env: create_env_from_core_vars(), with_escalated_permissions: None, @@ -59,6 +61,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { params, SandboxType::LinuxSeccomp, &sandbox_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) @@ -133,6 +136,7 @@ async fn test_timeout() { #[expect(clippy::expect_used)] async fn assert_network_blocked(cmd: &[&str]) { let cwd = std::env::current_dir().expect("cwd should exist"); + let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().map(|s| s.to_string()).collect(), cwd, @@ -151,6 +155,7 @@ async fn assert_network_blocked(cmd: &[&str]) { params, SandboxType::LinuxSeccomp, &sandbox_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index af0ffefeda..1d4a1b0fbb 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -589,12 +589,14 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let req_id = request_id; + let sandbox_cwd = self.config.cwd.clone(); tokio::spawn(async move { match codex_core::exec::process_exec_tool_call( exec_params, sandbox_type, &effective_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) From de64f5f0077d67eae73da1809549c5c967736553 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 18 Sep 2025 16:07:38 -0700 Subject: [PATCH 16/42] fix: update try_parse_word_only_commands_sequence() to return commands in order (#3881) Incidentally, we had a test for this in `accepts_multiple_commands_with_allowed_operators()`, but it was verifying the bad behavior. Oops! --- codex-rs/core/src/bash.rs | 9 ++++++--- codex-rs/core/src/parse_command.rs | 6 ++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 5b94daf252..4bed3a9c16 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -73,6 +73,9 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option> = vec![ - vec!["wc".to_string(), "-l".to_string()], - vec!["echo".to_string(), "hi there".to_string()], - vec!["pwd".to_string()], vec!["ls".to_string()], + vec!["pwd".to_string()], + vec!["echo".to_string(), "hi there".to_string()], + vec!["wc".to_string(), "-l".to_string()], ]; assert_eq!(cmds, expected); } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index ea886d61f0..1d2948a406 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1156,10 +1156,8 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { // bias toward the primary command when pipelines are present. // First, drop obvious small formatting helpers (e.g., wc/awk/etc). let had_multiple_commands = all_commands.len() > 1; - // The bash AST walker yields commands in right-to-left order for - // connector/pipeline sequences. Reverse to reflect actual execution order. - let mut filtered_commands = drop_small_formatting_commands(all_commands); - filtered_commands.reverse(); + // Commands arrive in source order; drop formatting helpers while preserving it. + let filtered_commands = drop_small_formatting_commands(all_commands); if filtered_commands.is_empty() { return Some(vec![ParsedCommand::Unknown { cmd: script.clone(), From a7fda7005344b754ddb3303704373ad0def1312c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 18 Sep 2025 17:08:28 -0700 Subject: [PATCH 17/42] Use a unified shell tell to not break cache (#3814) Currently, we change the tool description according to the sandbox policy and approval policy. This breaks the cache when the user hits `/approvals`. This PR does the following: - Always use the shell with escalation parameter: - removes `create_shell_tool_for_sandbox` and always uses unified tool via `create_shell_tool` - Reject the func call when the model uses escalation parameter when it cannot. --- codex-rs/core/src/codex.rs | 127 +++++++++++++++++++++++++--- codex-rs/core/src/exec.rs | 2 +- codex-rs/core/src/openai_tools.rs | 132 ++++-------------------------- 3 files changed, 133 insertions(+), 128 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e9f52b1c94..8852b25834 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -456,8 +456,6 @@ impl Session { client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - approval_policy, - sandbox_policy: sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1237,8 +1235,6 @@ async fn submission_loop( let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &effective_family, - approval_policy: new_approval_policy, - sandbox_policy: new_sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1325,8 +1321,6 @@ async fn submission_loop( client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy, - sandbox_policy: sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1553,8 +1547,6 @@ async fn spawn_review_thread( .unwrap_or_else(|| parent_turn_context.client.get_model_family()); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, - approval_policy: parent_turn_context.approval_policy, - sandbox_policy: parent_turn_context.sandbox_policy.clone(), include_plan_tool: false, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: false, @@ -2724,6 +2716,21 @@ async fn handle_container_exec_with_params( sub_id: String, call_id: String, ) -> ResponseInputItem { + if params.with_escalated_permissions.unwrap_or(false) + && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) + { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy + ), + success: None, + }, + }; + } + // check if this was a patch, and apply it if so let apply_patch_exec = match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) { MaybeApplyPatchVerified::Body(changes) => { @@ -3345,6 +3352,7 @@ mod tests { use mcp_types::ContentBlock; use mcp_types::TextContent; use pretty_assertions::assert_eq; + use serde::Deserialize; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; @@ -3594,8 +3602,6 @@ mod tests { ); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - approval_policy: config.approval_policy, - sandbox_policy: config.sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -3735,4 +3741,105 @@ mod tests { (rollout_items, live_history.contents()) } + + #[tokio::test] + async fn rejects_escalated_permissions_when_policy_not_on_request() { + use crate::exec::ExecParams; + use crate::protocol::AskForApproval; + use crate::protocol::SandboxPolicy; + use crate::turn_diff_tracker::TurnDiffTracker; + use std::collections::HashMap; + + let (session, mut turn_context) = make_session_and_context(); + // Ensure policy is NOT OnRequest so the early rejection path triggers + turn_context.approval_policy = AskForApproval::OnFailure; + + let params = ExecParams { + command: if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/C".to_string(), + "echo hi".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ] + }, + cwd: turn_context.cwd.clone(), + timeout_ms: Some(1000), + env: HashMap::new(), + with_escalated_permissions: Some(true), + justification: Some("test".to_string()), + }; + + let params2 = ExecParams { + with_escalated_permissions: Some(false), + ..params.clone() + }; + + let mut turn_diff_tracker = TurnDiffTracker::new(); + + let sub_id = "test-sub".to_string(); + let call_id = "test-call".to_string(); + + let resp = handle_container_exec_with_params( + params, + &session, + &turn_context, + &mut turn_diff_tracker, + sub_id, + call_id, + ) + .await; + + let ResponseInputItem::FunctionCallOutput { output, .. } = resp else { + panic!("expected FunctionCallOutput"); + }; + + let expected = format!( + "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy + ); + + pretty_assertions::assert_eq!(output.content, expected); + + // Now retry the same command WITHOUT escalated permissions; should succeed. + // Force DangerFullAccess to avoid platform sandbox dependencies in tests. + turn_context.sandbox_policy = SandboxPolicy::DangerFullAccess; + + let resp2 = handle_container_exec_with_params( + params2, + &session, + &turn_context, + &mut turn_diff_tracker, + "test-sub".to_string(), + "test-call-2".to_string(), + ) + .await; + + let ResponseInputItem::FunctionCallOutput { output, .. } = resp2 else { + panic!("expected FunctionCallOutput on retry"); + }; + + #[derive(Deserialize, PartialEq, Eq, Debug)] + struct ResponseExecMetadata { + exit_code: i32, + } + + #[derive(Deserialize)] + struct ResponseExecOutput { + output: String, + metadata: ResponseExecMetadata, + } + + let exec_output: ResponseExecOutput = + serde_json::from_str(&output.content).expect("valid exec output json"); + + pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); + assert!(exec_output.output.contains("hi")); + pretty_assertions::assert_eq!(output.success, Some(true)); + } } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9e11604c39..d84bbc9fcb 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -45,7 +45,7 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub struct ExecParams { pub command: Vec, pub cwd: PathBuf, diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index b57d1b7692..05b71ce6ea 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -7,8 +7,6 @@ use std::collections::HashMap; use crate::model_family::ModelFamily; use crate::plan_tool::PLAN_TOOL; -use crate::protocol::AskForApproval; -use crate::protocol::SandboxPolicy; use crate::tool_apply_patch::ApplyPatchToolType; use crate::tool_apply_patch::create_apply_patch_freeform_tool; use crate::tool_apply_patch::create_apply_patch_json_tool; @@ -57,10 +55,9 @@ pub(crate) enum OpenAiTool { #[derive(Debug, Clone)] pub enum ConfigShellToolType { - DefaultShell, - ShellWithRequest { sandbox_policy: SandboxPolicy }, - LocalShell, - StreamableShell, + Default, + Local, + Streamable, } #[derive(Debug, Clone)] @@ -75,8 +72,6 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_family: &'a ModelFamily, - pub(crate) approval_policy: AskForApproval, - pub(crate) sandbox_policy: SandboxPolicy, pub(crate) include_plan_tool: bool, pub(crate) include_apply_patch_tool: bool, pub(crate) include_web_search_request: bool, @@ -89,8 +84,6 @@ impl ToolsConfig { pub fn new(params: &ToolsConfigParams) -> Self { let ToolsConfigParams { model_family, - approval_policy, - sandbox_policy, include_plan_tool, include_apply_patch_tool, include_web_search_request, @@ -98,18 +91,13 @@ impl ToolsConfig { include_view_image_tool, experimental_unified_exec_tool, } = params; - let mut shell_type = if *use_streamable_shell_tool { - ConfigShellToolType::StreamableShell + let shell_type = if *use_streamable_shell_tool { + ConfigShellToolType::Streamable } else if model_family.uses_local_shell_tool { - ConfigShellToolType::LocalShell + ConfigShellToolType::Local } else { - ConfigShellToolType::DefaultShell + ConfigShellToolType::Default }; - if matches!(approval_policy, AskForApproval::OnRequest) && !use_streamable_shell_tool { - shell_type = ConfigShellToolType::ShellWithRequest { - sandbox_policy: sandbox_policy.clone(), - } - } let apply_patch_tool_type = match model_family.apply_patch_tool_type { Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), @@ -170,40 +158,6 @@ pub(crate) enum JsonSchema { }, } -fn create_shell_tool() -> OpenAiTool { - let mut properties = BTreeMap::new(); - properties.insert( - "command".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { description: None }), - description: Some("The command to execute".to_string()), - }, - ); - properties.insert( - "workdir".to_string(), - JsonSchema::String { - description: Some("The working directory to execute the command in".to_string()), - }, - ); - properties.insert( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ); - - OpenAiTool::Function(ResponsesApiTool { - name: "shell".to_string(), - description: "Runs a shell command and returns its output".to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["command".to_string()]), - additional_properties: Some(false), - }, - }) -} - fn create_unified_exec_tool() -> OpenAiTool { let mut properties = BTreeMap::new(); properties.insert( @@ -251,7 +205,7 @@ fn create_unified_exec_tool() -> OpenAiTool { }) } -fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { +fn create_shell_tool() -> OpenAiTool { let mut properties = BTreeMap::new(); properties.insert( "command".to_string(), @@ -273,20 +227,18 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { }, ); - if !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - properties.insert( + properties.insert( "with_escalated_permissions".to_string(), JsonSchema::Boolean { description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), }, ); - properties.insert( + properties.insert( "justification".to_string(), JsonSchema::String { description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), }, ); - } OpenAiTool::Function(ResponsesApiTool { name: "shell".to_string(), @@ -537,16 +489,13 @@ pub(crate) fn get_openai_tools( tools.push(create_unified_exec_tool()); } else { match &config.shell_type { - ConfigShellToolType::DefaultShell => { + ConfigShellToolType::Default => { tools.push(create_shell_tool()); } - ConfigShellToolType::ShellWithRequest { sandbox_policy } => { - tools.push(create_shell_tool_for_sandbox(sandbox_policy)); - } - ConfigShellToolType::LocalShell => { + ConfigShellToolType::Local => { tools.push(OpenAiTool::LocalShell {}); } - ConfigShellToolType::StreamableShell => { + ConfigShellToolType::Streamable => { tools.push(OpenAiTool::Function( crate::exec_command::create_exec_command_tool_for_responses_api(), )); @@ -636,8 +585,6 @@ mod tests { .expect("codex-mini-latest should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, @@ -658,8 +605,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, @@ -680,8 +625,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -786,8 +729,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: false, @@ -864,8 +805,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -927,8 +866,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -985,8 +922,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -1046,8 +981,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -1100,29 +1033,8 @@ 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 = "Runs a shell command and returns its output."; - assert_eq!(description, expected); - } - - #[test] - fn test_shell_tool_for_sandbox_readonly() { - let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly); + fn test_shell_tool() { + let tool = super::create_shell_tool(); let OpenAiTool::Function(ResponsesApiTool { description, name, .. }) = &tool @@ -1134,18 +1046,4 @@ mod tests { let expected = "Runs a shell command and returns its output."; 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."); - } } From 881c7978f1362b7eb4009777d0907fdc7e81b4e3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Sep 2025 17:53:14 -0700 Subject: [PATCH 18/42] Move responses mocking helpers to a shared lib (#3878) These are generally useful --- codex-rs/Cargo.lock | 1 + codex-rs/core/tests/common/Cargo.toml | 1 + codex-rs/core/tests/common/lib.rs | 2 + codex-rs/core/tests/common/responses.rs | 100 +++++++++++ codex-rs/core/tests/suite/compact.rs | 160 ++++-------------- .../core/tests/suite/compact_resume_fork.rs | 8 +- 6 files changed, 141 insertions(+), 131 deletions(-) create mode 100644 codex-rs/core/tests/common/responses.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e3930b507a..73c1117af1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1077,6 +1077,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "wiremock", ] [[package]] diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 9cfd20cdb4..2d43051919 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,3 +11,4 @@ codex-core = { path = "../.." } serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["time"] } +wiremock = "0.6" diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 244d093e7d..95af79b220 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -7,6 +7,8 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +pub mod responses; + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs new file mode 100644 index 0000000000..dc6f849ee7 --- /dev/null +++ b/codex-rs/core/tests/common/responses.rs @@ -0,0 +1,100 @@ +use serde_json::Value; +use wiremock::BodyPrintLimit; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Build an SSE stream body from a list of JSON events. +pub fn sse(events: Vec) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + for ev in events { + let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); + writeln!(&mut out, "event: {kind}").unwrap(); + if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { + write!(&mut out, "data: {ev}\n\n").unwrap(); + } else { + out.push('\n'); + } + } + out +} + +/// Convenience: SSE event for a completed response with a specific id. +pub fn ev_completed(id: &str) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} + } + }) +} + +pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": total_tokens, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": total_tokens + } + } + }) +} + +/// Convenience: SSE event for a single assistant message output item. +pub fn ev_assistant_message(id: &str, text: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": id, + "content": [{"type": "output_text", "text": text}] + } + }) +} + +pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments + } + }) +} + +pub fn sse_response(body: String) -> ResponseTemplate { + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(body, "text/event-stream") +} + +pub async fn mount_sse_once(server: &MockServer, matcher: M, body: String) +where + M: wiremock::Match + Send + Sync + 'static, +{ + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(matcher) + .respond_with(sse_response(body)) + .mount(server) + .await; +} + +pub async fn start_mock_server() -> MockServer { + MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 8db70f3559..a58de304fb 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,3 @@ -#![expect(clippy::unwrap_used)] - use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; @@ -13,12 +11,10 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::wait_for_event; -use serde_json::Value; use tempfile::TempDir; -use wiremock::BodyPrintLimit; use wiremock::Mock; -use wiremock::MockServer; use wiremock::Request; use wiremock::Respond; use wiremock::ResponseTemplate; @@ -26,106 +22,16 @@ use wiremock::matchers::method; use wiremock::matchers::path; use pretty_assertions::assert_eq; +use responses::ev_assistant_message; +use responses::ev_completed; +use responses::sse; +use responses::start_mock_server; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - // --- Test helpers ----------------------------------------------------------- -/// Build an SSE stream body from a list of JSON events. -pub(super) fn sse(events: Vec) -> String { - use std::fmt::Write as _; - let mut out = String::new(); - for ev in events { - let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); - writeln!(&mut out, "event: {kind}").unwrap(); - if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { - write!(&mut out, "data: {ev}\n\n").unwrap(); - } else { - out.push('\n'); - } - } - out -} - -/// Convenience: SSE event for a completed response with a specific id. -pub(super) fn ev_completed(id: &str) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - -fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": { - "input_tokens": total_tokens, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": total_tokens - } - } - }) -} - -/// Convenience: SSE event for a single assistant message output item. -pub(super) fn ev_assistant_message(id: &str, text: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "id": id, - "content": [{"type": "output_text", "text": text}] - } - }) -} - -fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments - } - }) -} - -pub(super) fn sse_response(body: String) -> ResponseTemplate { - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(body, "text/event-stream") -} - -pub(super) async fn mount_sse_once(server: &MockServer, matcher: M, body: String) -where - M: wiremock::Match + Send + Sync + 'static, -{ - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(matcher) - .respond_with(sse_response(body)) - .mount(server) - .await; -} - -async fn start_mock_server() -> MockServer { - MockServer::builder() - .body_print_limit(BodyPrintLimit::Limited(80_000)) - .start() - .await -} - pub(super) const FIRST_REPLY: &str = "FIRST_REPLY"; pub(super) const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT"; pub(super) const SUMMARIZE_TRIGGER: &str = "Start Summarization"; @@ -175,19 +81,19 @@ async fn summarize_context_three_requests_and_instructions() { body.contains("\"text\":\"hello world\"") && !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, first_matcher, sse1).await; + responses::mount_sse_once(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, second_matcher, sse2).await; + responses::mount_sse_once(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - mount_sse_once(&server, third_matcher, sse3).await; + responses::mount_sse_once(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { @@ -381,17 +287,17 @@ async fn auto_compact_runs_after_token_limit_hit() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -403,7 +309,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -416,7 +322,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -427,7 +333,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -522,17 +428,17 @@ async fn auto_compact_persists_rollout_entries() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -544,7 +450,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -557,7 +463,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -568,7 +474,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -655,17 +561,17 @@ async fn auto_compact_stops_after_failed_attempt() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", SUMMARY_TEXT), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ ev_assistant_message("m3", STILL_TOO_BIG_REPLY), - ev_completed_with_tokens("r3", 500), + responses::ev_completed_with_tokens("r3", 500), ]); let first_matcher = |req: &wiremock::Request| { @@ -676,7 +582,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1.clone())) + .respond_with(responses::sse_response(sse1.clone())) .mount(&server) .await; @@ -687,7 +593,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2.clone())) + .respond_with(responses::sse_response(sse2.clone())) .mount(&server) .await; @@ -699,7 +605,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3.clone())) + .respond_with(responses::sse_response(sse3.clone())) .mount(&server) .await; @@ -769,27 +675,27 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", FIRST_AUTO_SUMMARY), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ - ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - ev_completed_with_tokens("r3", 150), + responses::ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r3", 150), ]); let sse4 = sse(vec![ ev_assistant_message("m4", SECOND_LARGE_REPLY), - ev_completed_with_tokens("r4", 450), + responses::ev_completed_with_tokens("r4", 450), ]); let sse5 = sse(vec![ ev_assistant_message("m5", SECOND_AUTO_SUMMARY), - ev_completed_with_tokens("r5", 60), + responses::ev_completed_with_tokens("r5", 60), ]); let sse6 = sse(vec![ ev_assistant_message("m6", FINAL_REPLY), - ev_completed_with_tokens("r6", 120), + responses::ev_completed_with_tokens("r6", 120), ]); #[derive(Clone)] diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 1bedbcab08..1e752826bb 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,10 +10,6 @@ use super::compact::FIRST_REPLY; use super::compact::SUMMARIZE_TRIGGER; use super::compact::SUMMARY_TEXT; -use super::compact::ev_assistant_message; -use super::compact::ev_completed; -use super::compact::mount_sse_once; -use super::compact::sse; use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; @@ -27,6 +23,10 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; From 9b18875a42c6453ff79a8ae7a658e5e615f7b353 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 19 Sep 2025 06:46:25 -0700 Subject: [PATCH 19/42] Use helpers instead of fixtures (#3888) Move to using test helper method everywhere. --- codex-rs/core/tests/common/responses.rs | 33 +++++++++ .../tests/fixtures/sse_apply_patch_add.json | 25 ------- .../sse_apply_patch_freeform_add.json | 25 ------- .../sse_apply_patch_freeform_update.json | 25 ------- .../fixtures/sse_apply_patch_update.json | 25 ------- .../fixtures/sse_response_completed.json | 16 ----- codex-rs/exec/tests/suite/apply_patch.rs | 71 ++++++++++++++----- codex-rs/exec/tests/suite/common.rs | 6 +- 8 files changed, 86 insertions(+), 140 deletions(-) delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_add.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_update.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_response_completed.json diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index dc6f849ee7..2f55f17a52 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -74,6 +74,39 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { }) } +/// Convenience: SSE event for an `apply_patch` custom tool call with raw patch +/// text. This mirrors the payload produced by the Responses API when the model +/// invokes `apply_patch` directly (before we convert it to a function call). +pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": patch, + "call_id": call_id + } + }) +} + +/// Convenience: SSE event for an `apply_patch` function call. The Responses API +/// wraps the patch content in a JSON string under the `input` key; we recreate +/// the same structure so downstream code exercises the full parsing path. +pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value { + let arguments = serde_json::json!({ "input": patch }); + let arguments = serde_json::to_string(&arguments).expect("serialize apply_patch arguments"); + + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "apply_patch", + "arguments": arguments, + "call_id": call_id + } + }) +} + pub fn sse_response(body: String) -> ResponseTemplate { ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json deleted file mode 100644 index 8d2bf261af..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json deleted file mode 100644 index ce05e7d482..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: app.py\n+class BaseClass:\n+ def method():\n+ return False\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json deleted file mode 100644 index 8329d9628c..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Update File: app.py\n@@ def method():\n- return False\n+\n+ return True\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json deleted file mode 100644 index 79689bece3..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "function_call", - "name": "apply_patch", - "arguments": "{\n \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_response_completed.json b/codex-rs/exec/tests/fixtures/sse_response_completed.json deleted file mode 100644 index 1774dc5e84..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_response_completed.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 5537853b02..489f34f9ce 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -1,8 +1,12 @@ -#![allow(clippy::expect_used, clippy::unwrap_used)] +#![allow(clippy::expect_used, clippy::unwrap_used, unused_imports)] use anyhow::Context; use assert_cmd::prelude::*; use codex_core::CODEX_APPLY_PATCH_ARG1; +use core_test_support::responses::ev_apply_patch_custom_tool_call; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_completed; +use core_test_support::responses::sse; use std::fs; use std::process::Command; use tempfile::tempdir; @@ -55,15 +59,28 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { let tmp_cwd = tempdir().expect("failed to create temp dir"); let tmp_path = tmp_cwd.path().to_path_buf(); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let add_patch = r#"*** Begin Patch +*** Add File: test.md ++Hello world +*** End Patch"#; + let update_patch = r#"*** Begin Patch +*** Update File: test.md +@@ +-Hello world ++Final text +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_function_call("request_1", update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; let final_path = tmp_path.join("test.md"); let contents = std::fs::read_to_string(&final_path) @@ -86,15 +103,31 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> { } let tmp_cwd = tempdir().expect("failed to create temp dir"); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_freeform_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let freeform_add_patch = r#"*** Begin Patch +*** Add File: app.py ++class BaseClass: ++ def method(): ++ return False +*** End Patch"#; + let freeform_update_patch = r#"*** Begin Patch +*** Update File: app.py +@@ def method(): +- return False ++ ++ return True +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", freeform_add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_custom_tool_call("request_1", freeform_update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; // Verify final file contents let final_path = tmp_cwd.path().join("app.py"); diff --git a/codex-rs/exec/tests/suite/common.rs b/codex-rs/exec/tests/suite/common.rs index 4a3719aaba..19de9a3ef6 100644 --- a/codex-rs/exec/tests/suite/common.rs +++ b/codex-rs/exec/tests/suite/common.rs @@ -4,7 +4,6 @@ use anyhow::Context; use assert_cmd::prelude::*; -use core_test_support::load_sse_fixture_with_id_from_str; use std::path::Path; use std::process::Command; use std::sync::atomic::AtomicUsize; @@ -27,10 +26,7 @@ impl Respond for SeqResponder { match self.responses.get(call_num) { Some(body) => wiremock::ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") - .set_body_raw( - load_sse_fixture_with_id_from_str(body, &format!("request_{call_num}")), - "text/event-stream", - ), + .set_body_string(body.clone()), None => panic!("no response for {call_num}"), } } From ff389dc52f257d60d1978d34938dc7ab3cefdd15 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:08:04 -0700 Subject: [PATCH 20/42] fix alignment in slash command popup (#3937) --- codex-rs/tui/src/bottom_pane/command_popup.rs | 77 ++++++++----------- .../src/bottom_pane/selection_popup_common.rs | 27 ++++++- ..._chat_composer__tests__slash_popup_mo.snap | 4 +- ..._list_selection_spacing_with_subtitle.snap | 4 +- ...st_selection_spacing_without_subtitle.snap | 4 +- 5 files changed, 60 insertions(+), 56 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8de266f819..a492a2e17e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -95,32 +95,10 @@ impl CommandPopup { /// Determine the preferred height of the popup for a given width. /// Accounts for wrapped descriptions so that long tooltips don't overflow. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { - use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; - let matches = self.filtered(); - let rows_all: Vec = if matches.is_empty() { - Vec::new() - } else { - matches - .into_iter() - .map(|(item, indices, _)| match item { - CommandItem::Builtin(cmd) => GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), - }, - CommandItem::UserPrompt(i) => GenericDisplayRow { - name: format!("/{}", self.prompts[i].name), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some("send saved prompt".to_string()), - }, - }) - .collect() - }; + let rows = self.rows_from_matches(self.filtered()); - measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width) + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } /// Compute fuzzy-filtered matches over built-in commands and user prompts, @@ -172,6 +150,32 @@ impl CommandPopup { self.filtered().into_iter().map(|(c, _, _)| c).collect() } + fn rows_from_matches( + &self, + matches: Vec<(CommandItem, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(item, indices, _)| { + let (name, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::UserPrompt(i) => ( + format!("/{}", self.prompts[i].name), + "send saved prompt".to_string(), + ), + }; + GenericDisplayRow { + name, + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(description), + } + }) + .collect() + } + /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); @@ -198,32 +202,11 @@ impl CommandPopup { impl WidgetRef for CommandPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let matches = self.filtered(); - let rows_all: Vec = if matches.is_empty() { - Vec::new() - } else { - matches - .into_iter() - .map(|(item, indices, _)| match item { - CommandItem::Builtin(cmd) => GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), - }, - CommandItem::UserPrompt(i) => GenericDisplayRow { - name: format!("/{}", self.prompts[i].name), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some("send saved prompt".to_string()), - }, - }) - .collect() - }; + let rows = self.rows_from_matches(self.filtered()); render_rows( area, buf, - &rows_all, + &rows, &self.state, MAX_POPUP_ROWS, false, 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 61a26f9341..684924a44a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -15,6 +15,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use super::scroll_state::ScrollState; +use crate::ui_consts::LIVE_PREFIX_COLS; /// A generic representation of a display row for selection popups. pub(crate) struct GenericDisplayRow { @@ -99,14 +100,34 @@ pub(crate) fn render_rows( .border_style(Style::default().add_modifier(Modifier::DIM)); block.render(area, buf); - // Content renders to the right of the border. + // Content renders to the right of the border with the same live prefix + // padding used by the composer so the popup aligns with the input text. + let prefix_cols = LIVE_PREFIX_COLS; let content_area = Rect { - x: area.x.saturating_add(1), + x: area.x.saturating_add(prefix_cols), y: area.y, - width: area.width.saturating_sub(1), + width: area.width.saturating_sub(prefix_cols), height: area.height, }; + // Clear the padding column(s) so stale characters never peek between the + // border and the popup contents. + let padding_cols = prefix_cols.saturating_sub(1); + if padding_cols > 0 { + let pad_start = area.x.saturating_add(1); + let pad_end = pad_start + .saturating_add(padding_cols) + .min(area.x.saturating_add(area.width)); + let pad_bottom = area.y.saturating_add(area.height); + for x in pad_start..pad_end { + for y in area.y..pad_bottom { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(" "); + } + } + } + } + if rows_all.is_empty() { if content_area.height > 0 { let para = Paragraph::new(Line::from(empty_message.dim().italic())); 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 index f908fb6144..9c667c0d08 100644 --- 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 @@ -4,5 +4,5 @@ expression: terminal.backend() --- "▌ /mo " "▌ " -"▌/model choose what model and reasoning effort to use " -"▌/mention mention a file " +"▌ /model choose what model and reasoning effort to use " +"▌ /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index 65606ed7d0..ced53b7ce6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -5,7 +5,7 @@ expression: render_lines(&view) ▌ Select Approval Mode ▌ Switch between Codex approval presets ▌ -▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ > 1. Read Only (current) Codex can read files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index b42a5f8c6b..b9858a4307 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -4,7 +4,7 @@ expression: render_lines(&view) --- ▌ Select Approval Mode ▌ -▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ > 1. Read Only (current) Codex can read files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back From ad0c2b4db302cdc89b9c52e33ec780dae85cb46a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:22:58 -0700 Subject: [PATCH 21/42] don't clear screen on startup (#3925) --- codex-rs/tui/src/tui.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 6d7b9e8101..cacbc7165c 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -14,7 +14,7 @@ use std::time::Instant; use crossterm::Command; use crossterm::SynchronizedUpdate; -use crossterm::cursor; +#[cfg(unix)] use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; @@ -27,7 +27,6 @@ use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; -use crossterm::terminal::ScrollUp; use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; @@ -127,15 +126,6 @@ pub fn init() -> Result { set_panic_hook(); - // Instead of clearing the screen (which can drop scrollback in some terminals), - // scroll existing lines up until the cursor reaches the top, then start at (0, 0). - if let Ok((_x, y)) = cursor::position() - && y > 0 - { - execute!(stdout(), ScrollUp(y))?; - } - execute!(stdout(), MoveTo(0, 0))?; - let backend = CrosstermBackend::new(stdout()); let tui = CustomTerminal::with_options(backend)?; Ok(tui) From 42d335deb878c2b558e663bd3f6a2d52c578db05 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:38:36 -0700 Subject: [PATCH 22/42] Cache keyboard enhancement detection before event streams (#3950) Hopefully fixes incorrectly showing ^J instead of Shift+Enter in the key hints occasionally. --- codex-rs/tui/src/app.rs | 3 +-- codex-rs/tui/src/tui.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bd9150cad0..f6b735b10b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -22,7 +22,6 @@ use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::terminal::supports_keyboard_enhancement; use ratatui::style::Stylize; use ratatui::text::Line; use std::path::PathBuf; @@ -85,7 +84,7 @@ impl App { let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); - let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); + let enhanced_keys_supported = tui.enhanced_keys_supported(); let chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index cacbc7165c..eeabec4e04 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -27,6 +27,7 @@ use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; +use crossterm::terminal::supports_keyboard_enhancement; use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; @@ -160,6 +161,7 @@ pub struct Tui { alt_screen_active: Arc, // True when terminal/tab is focused; updated internally from crossterm events terminal_focused: Arc, + enhanced_keys_supported: bool, } #[cfg(unix)] @@ -266,6 +268,10 @@ impl Tui { } }); + // Detect keyboard enhancement support before any EventStream is created so the + // crossterm poller can acquire its lock without contention. + let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); + Self { frame_schedule_tx, draw_tx, @@ -278,6 +284,7 @@ impl Tui { suspend_cursor_y: Arc::new(AtomicU16::new(0)), alt_screen_active: Arc::new(AtomicBool::new(false)), terminal_focused: Arc::new(AtomicBool::new(true)), + enhanced_keys_supported, } } @@ -287,6 +294,10 @@ impl Tui { } } + pub fn enhanced_keys_supported(&self) -> bool { + self.enhanced_keys_supported + } + pub fn event_stream(&self) -> Pin + Send + 'static>> { use tokio_stream::StreamExt; let mut crossterm_events = crossterm::event::EventStream::new(); From 04504d8218465326ed19f967e773b29b152e916b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 20 Sep 2025 21:26:16 -0700 Subject: [PATCH 23/42] Forward Rate limits to the UI (#3965) We currently get information about rate limits in the response headers. We want to forward them to the clients to have better transparency. UI/UX plans have been discussed and this information is needed. --- codex-rs/core/src/chat_completions.rs | 3 + codex-rs/core/src/client.rs | 43 ++++++++++++ codex-rs/core/src/client_common.rs | 2 + codex-rs/core/src/codex.rs | 46 +++++++++---- codex-rs/core/tests/suite/client.rs | 95 +++++++++++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 15 +++++ 6 files changed, 192 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index fc8602de8e..f666cc119f 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -716,6 +716,9 @@ where // Not an assistant message – forward immediately. return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); } + Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); + } Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id, token_usage, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 055c3afa87..57848d388b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -11,6 +11,7 @@ use eventsource_stream::Eventsource; use futures::prelude::*; use regex_lite::Regex; use reqwest::StatusCode; +use reqwest::header::HeaderMap; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -40,6 +41,7 @@ use crate::model_provider_info::ModelProviderInfo; 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::RateLimitSnapshotEvent; use crate::protocol::TokenUsage; use crate::token_data::PlanType; use crate::util::backoff; @@ -274,6 +276,15 @@ impl ModelClient { Ok(resp) if resp.status().is_success() => { let (tx_event, rx_event) = mpsc::channel::>(1600); + if let Some(snapshot) = parse_rate_limit_snapshot(resp.headers()) + && tx_event + .send(Ok(ResponseEvent::RateLimits(snapshot))) + .await + .is_err() + { + debug!("receiver dropped rate limit snapshot event"); + } + // spawn task to process SSE let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); tokio::spawn(process_sse( @@ -473,6 +484,38 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { } } +fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { + let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?; + let weekly_used_percent = parse_header_f64(headers, "x-codex-protection-used-percent")?; + let primary_to_weekly_ratio_percent = + parse_header_f64(headers, "x-codex-primary-over-protection-limit-percent")?; + let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?; + let weekly_window_minutes = parse_header_u64(headers, "x-codex-protection-window-minutes")?; + + Some(RateLimitSnapshotEvent { + primary_used_percent, + weekly_used_percent, + primary_to_weekly_ratio_percent, + primary_window_minutes, + weekly_window_minutes, + }) +} + +fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)? + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() +} + +fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name)?.to_str().ok() +} + async fn process_sse( stream: S, tx_event: mpsc::Sender>, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 3a5eb5b16b..15bfb5d400 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,6 +1,7 @@ use crate::error::Result; use crate::model_family::ModelFamily; use crate::openai_tools::OpenAiTool; +use crate::protocol::RateLimitSnapshotEvent; use crate::protocol::TokenUsage; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -78,6 +79,7 @@ pub enum ResponseEvent { WebSearchCallBegin { call_id: String, }, + RateLimits(RateLimitSnapshotEvent), } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8852b25834..05ef09377e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -98,6 +98,7 @@ use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; use crate::protocol::PatchApplyBeginEvent; use crate::protocol::PatchApplyEndEvent; +use crate::protocol::RateLimitSnapshotEvent; use crate::protocol::ReviewDecision; use crate::protocol::ReviewOutputEvent; use crate::protocol::SandboxPolicy; @@ -105,6 +106,7 @@ use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; +use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; use crate::protocol::TurnDiffEvent; @@ -257,6 +259,7 @@ struct State { pending_input: Vec, history: ConversationHistory, token_info: Option, + latest_rate_limits: Option, } /// Context for an initialized model agent @@ -738,16 +741,30 @@ impl Session { async fn update_token_usage_info( &self, turn_context: &TurnContext, - token_usage: &Option, - ) -> Option { + token_usage: Option<&TokenUsage>, + ) { let mut state = self.state.lock().await; - let info = TokenUsageInfo::new_or_append( - &state.token_info, - token_usage, - turn_context.client.get_model_context_window(), - ); - state.token_info = info.clone(); - info + if let Some(token_usage) = token_usage { + let info = TokenUsageInfo::new_or_append( + &state.token_info, + &Some(token_usage.clone()), + turn_context.client.get_model_context_window(), + ); + state.token_info = info; + } + } + + async fn update_rate_limits(&self, new_rate_limits: RateLimitSnapshotEvent) { + let mut state = self.state.lock().await; + state.latest_rate_limits = Some(new_rate_limits); + } + + async fn get_token_count_event(&self) -> TokenCountEvent { + let state = self.state.lock().await; + TokenCountEvent { + info: state.token_info.clone(), + rate_limits: state.latest_rate_limits.clone(), + } } /// Record a user input item to conversation history and also persist a @@ -2136,17 +2153,22 @@ async fn try_run_turn( }) .await; } + ResponseEvent::RateLimits(snapshot) => { + // Update internal state with latest rate limits, but defer sending until + // token usage is available to avoid duplicate TokenCount events. + sess.update_rate_limits(snapshot).await; + } ResponseEvent::Completed { response_id: _, token_usage, } => { - let info = sess - .update_token_usage_info(turn_context, &token_usage) + sess.update_token_usage_info(turn_context, token_usage.as_ref()) .await; + let token_event = sess.get_token_count_event().await; let _ = sess .send_event(Event { id: sub_id.to_string(), - msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }), + msg: EventMsg::TokenCount(token_event), }) .await; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cfc6f5f40c..d0ae608cb0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,6 +22,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses; use core_test_support::wait_for_event; use futures::StreamExt; use serde_json::json; @@ -776,6 +777,100 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { assert_eq!(body["input"][5]["id"].as_str(), Some("custom-tool-id")); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_count_includes_rate_limits_snapshot() { + let server = MockServer::start().await; + + let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + + let response = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header("x-codex-primary-used-percent", "12.5") + .insert_header("x-codex-protection-used-percent", "40.0") + .insert_header("x-codex-primary-over-protection-limit-percent", "75.0") + .insert_header("x-codex-primary-window-minutes", "10") + .insert_header("x-codex-protection-window-minutes", "60") + .set_body_raw(sse_body, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + + let mut provider = built_in_model_providers()["openai"].clone(); + provider.base_url = Some(format!("{}/v1", server.uri())); + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = provider; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + let token_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await; + let final_payload = match token_event { + EventMsg::TokenCount(ev) => ev, + _ => unreachable!(), + }; + // Assert full JSON for the final token count event (usage + rate limits) + let final_json = serde_json::to_value(&final_payload).unwrap(); + pretty_assertions::assert_eq!( + final_json, + json!({ + "info": { + "total_token_usage": { + "input_tokens": 123, + "cached_input_tokens": 0, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 123 + }, + "last_token_usage": { + "input_tokens": 123, + "cached_input_tokens": 0, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 123 + }, + // Default model is gpt-5 in tests → 272000 context window + "model_context_window": 272000 + }, + "rate_limits": { + "primary_used_percent": 12.5, + "weekly_used_percent": 40.0, + "primary_to_weekly_ratio_percent": 75.0, + "primary_window_minutes": 10, + "weekly_window_minutes": 60 + } + }) + ); + let usage = final_payload + .info + .expect("token usage info should be recorded after completion"); + assert_eq!(usage.total_token_usage.total_tokens, 123); + let final_snapshot = final_payload + .rate_limits + .expect("latest rate limit snapshot should be retained"); + assert_eq!(final_snapshot.primary_used_percent, 12.5); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_overrides_assign_properties_used_for_responses_url() { let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8009ac9bf3..edcdcbebf8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -589,6 +589,21 @@ impl TokenUsageInfo { #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenCountEvent { pub info: Option, + pub rate_limits: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +pub struct RateLimitSnapshotEvent { + /// Percentage (0-100) of the primary window that has been consumed. + pub primary_used_percent: f64, + /// Percentage (0-100) of the protection window that has been consumed. + pub weekly_used_percent: f64, + /// Size of the primary window relative to weekly (0-100). + pub primary_to_weekly_ratio_percent: f64, + /// Rolling window duration for the primary limit, in minutes. + pub primary_window_minutes: u64, + /// Rolling window duration for the weekly limit, in minutes. + pub weekly_window_minutes: u64, } // Includes prompts, tools and space to call compact. From a4ebd069e566e91b5ad53f58ab337d0edbacc59e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 21 Sep 2025 10:20:49 -0700 Subject: [PATCH 24/42] Tui: Rate limits (#3977) ### /limits: show rate limits graph image ### Warning on close to rate limits: image Based on #3965 --- codex-rs/tui/src/chatwidget.rs | 75 ++- ...chatwidget__tests__limits_placeholder.snap | 10 + ...twidget__tests__limits_snapshot_basic.snap | 29 + ...sts__limits_snapshot_hourly_remaining.snap | 29 + ...t__tests__limits_snapshot_mixed_usage.snap | 29 + ...__tests__limits_snapshot_weekly_heavy.snap | 29 + codex-rs/tui/src/chatwidget/tests.rs | 157 ++++++ codex-rs/tui/src/history_cell.rs | 45 ++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/rate_limits_view.rs | 504 ++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 3 + 11 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap create mode 100644 codex-rs/tui/src/rate_limits_view.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e024fd0fab..cb8acd04fb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -28,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; @@ -103,6 +104,42 @@ struct RunningCommand { parsed_cmd: Vec, } +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0]; + +#[derive(Default)] +struct RateLimitWarningState { + weekly_index: usize, + hourly_index: usize, +} + +impl RateLimitWarningState { + fn take_warnings(&mut self, weekly_used_percent: f64, hourly_used_percent: f64) -> Vec { + let mut warnings = Vec::new(); + + while self.weekly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && weekly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index] + { + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]; + warnings.push(format!( + "Weekly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + )); + self.weekly_index += 1; + } + + while self.hourly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && hourly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index] + { + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]; + warnings.push(format!( + "Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + )); + self.hourly_index += 1; + } + + warnings + } +} + /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, @@ -124,6 +161,8 @@ pub(crate) struct ChatWidget { session_header: SessionHeader, initial_user_message: Option, token_info: Option, + rate_limit_snapshot: Option, + rate_limit_warnings: RateLimitWarningState, // Stream lifecycle controller stream: StreamController, running_commands: HashMap, @@ -285,6 +324,21 @@ impl ChatWidget { self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } + + fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(snapshot) = snapshot { + let warnings = self + .rate_limit_warnings + .take_warnings(snapshot.weekly_used_percent, snapshot.primary_used_percent); + self.rate_limit_snapshot = Some(snapshot); + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } + } /// Finalize any active exec as failed and stop/clear running UI state. fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. @@ -699,6 +753,8 @@ impl ChatWidget { initial_images, ), token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(config), running_commands: HashMap::new(), task_complete_pending: false, @@ -756,6 +812,8 @@ impl ChatWidget { initial_images, ), token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(config), running_commands: HashMap::new(), task_complete_pending: false, @@ -929,6 +987,9 @@ impl ChatWidget { SlashCommand::Status => { self.add_status_output(); } + SlashCommand::Limits => { + self.add_limits_output(); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -1106,7 +1167,10 @@ impl ChatWidget { EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { self.on_task_complete(last_agent_message) } - EventMsg::TokenCount(ev) => self.set_token_info(ev.info), + EventMsg::TokenCount(ev) => { + self.set_token_info(ev.info); + self.on_rate_limit_snapshot(ev.rate_limits); + } EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { @@ -1282,6 +1346,15 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn add_limits_output(&mut self) { + if let Some(snapshot) = &self.rate_limit_snapshot { + self.add_to_history(history_cell::new_limits_output(snapshot)); + } else { + self.add_to_history(history_cell::new_limits_unavailable()); + } + self.request_redraw(); + } + pub(crate) fn add_status_output(&mut self) { let default_usage; let usage_ref = if let Some(ti) = &self.token_info { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap new file mode 100644 index 0000000000..fa37b2201f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + Real usage data is not available yet. +[dim] Send a message to Codex, then run /limits again.[/] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap new file mode 100644 index 0000000000..ced4466837 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]30.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]60.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap new file mode 100644 index 0000000000..defc5f213c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]0.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap new file mode 100644 index 0000000000..86c82d9247 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]20.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap new file mode 100644 index 0000000000..a1650545b6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]98.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]0.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffe3f3f707..7427bb4414 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -25,6 +25,7 @@ use codex_core::protocol::InputMessageKind; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::ReviewCodeLocation; use codex_core::protocol::ReviewFinding; use codex_core::protocol::ReviewLineRange; @@ -39,6 +40,8 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +use ratatui::style::Color; +use ratatui::style::Modifier; use std::fs::File; use std::io::BufRead; use std::io::BufReader; @@ -320,6 +323,8 @@ fn make_chatwidget_manual() -> ( session_header: SessionHeader::new(cfg.model.clone()), initial_user_message: None, token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(cfg), running_commands: HashMap::new(), task_complete_pending: false, @@ -375,6 +380,158 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } +fn styled_lines_to_string(lines: &[ratatui::text::Line<'static>]) -> String { + let mut out = String::new(); + for line in lines { + for span in &line.spans { + let mut tags: Vec<&str> = Vec::new(); + if let Some(color) = span.style.fg { + let name = match color { + Color::Black => "black", + Color::Blue => "blue", + Color::Cyan => "cyan", + Color::DarkGray => "dark-gray", + Color::Gray => "gray", + Color::Green => "green", + Color::LightBlue => "light-blue", + Color::LightCyan => "light-cyan", + Color::LightGreen => "light-green", + Color::LightMagenta => "light-magenta", + Color::LightRed => "light-red", + Color::LightYellow => "light-yellow", + Color::Magenta => "magenta", + Color::Red => "red", + Color::Rgb(_, _, _) => "rgb", + Color::Indexed(_) => "indexed", + Color::Reset => "reset", + Color::Yellow => "yellow", + Color::White => "white", + }; + tags.push(name); + } + let modifiers = span.style.add_modifier; + if modifiers.contains(Modifier::BOLD) { + tags.push("bold"); + } + if modifiers.contains(Modifier::DIM) { + tags.push("dim"); + } + if modifiers.contains(Modifier::ITALIC) { + tags.push("italic"); + } + if modifiers.contains(Modifier::UNDERLINED) { + tags.push("underlined"); + } + if !tags.is_empty() { + out.push('['); + out.push_str(&tags.join("+")); + out.push(']'); + } + out.push_str(&span.content); + if !tags.is_empty() { + out.push_str("[/]"); + } + } + out.push('\n'); + } + out +} + +fn sample_rate_limit_snapshot( + primary_used_percent: f64, + weekly_used_percent: f64, + ratio_percent: f64, +) -> RateLimitSnapshotEvent { + RateLimitSnapshotEvent { + primary_used_percent, + weekly_used_percent, + primary_to_weekly_ratio_percent: ratio_percent, + primary_window_minutes: 300, + weekly_window_minutes: 10_080, + } +} + +fn capture_limits_snapshot(snapshot: Option) -> String { + let lines = match snapshot { + Some(ref snapshot) => history_cell::new_limits_output(snapshot).display_lines(80), + None => history_cell::new_limits_unavailable().display_lines(80), + }; + styled_lines_to_string(&lines) +} + +#[test] +fn limits_placeholder() { + let visual = capture_limits_snapshot(None); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_basic() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(30.0, 60.0, 40.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_hourly_remaining() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(0.0, 20.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_mixed_usage() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(20.0, 20.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_weekly_heavy() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(98.0, 0.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn rate_limit_warnings_emit_thresholds() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(10.0, 55.0)); + warnings.extend(state.take_warnings(55.0, 10.0)); + warnings.extend(state.take_warnings(10.0, 80.0)); + warnings.extend(state.take_warnings(80.0, 10.0)); + warnings.extend(state.take_warnings(10.0, 95.0)); + warnings.extend(state.take_warnings(95.0, 10.0)); + + assert_eq!( + warnings.len(), + 6, + "expected one warning per threshold per limit" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Hourly usage exceeded 50%")), + "expected hourly 50% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Weekly usage exceeded 50%")), + "expected weekly 50% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Hourly usage exceeded 90%")), + "expected hourly 90% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Weekly usage exceeded 90%")), + "expected weekly 90% warning" + ); +} + // (removed experimental resize snapshot test) #[test] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a99a4dd836..24e6b2844f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2,6 +2,9 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; +use crate::rate_limits_view::DEFAULT_GRID_CONFIG; +use crate::rate_limits_view::LimitsView; +use crate::rate_limits_view::build_limits_view; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; @@ -24,6 +27,7 @@ use codex_core::plan_tool::UpdatePlanArgs; use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; @@ -221,6 +225,20 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct LimitsHistoryCell { + display: LimitsView, +} + +impl HistoryCell for LimitsHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines = self.display.summary_lines.clone(); + lines.extend(self.display.gauge_lines(width)); + lines.extend(self.display.legend_lines.clone()); + lines + } +} + #[derive(Debug)] pub(crate) struct TranscriptOnlyHistoryCell { lines: Vec>, @@ -1075,6 +1093,33 @@ pub(crate) fn new_completed_mcp_tool_call( Box::new(PlainHistoryCell { lines }) } +pub(crate) fn new_limits_output(snapshot: &RateLimitSnapshotEvent) -> LimitsHistoryCell { + LimitsHistoryCell { + display: build_limits_view(snapshot, DEFAULT_GRID_CONFIG), + } +} + +pub(crate) fn new_limits_unavailable() -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![ + "/limits".magenta().into(), + "".into(), + vec!["Rate limit usage snapshot".bold()].into(), + vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()] + .into(), + vec![" Real usage data is not available yet.".into()].into(), + vec![" Send a message to Codex, then run /limits again.".dim()].into(), + ], + } +} + +#[allow(clippy::disallowed_methods)] +pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![vec![format!("⚠ {message}").yellow()].into()], + } +} + pub(crate) fn new_status_output( config: &Config, usage: &TokenUsage, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 308e563ded..92b42732c3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -55,6 +55,7 @@ mod markdown_stream; mod new_model_popup; pub mod onboarding; mod pager_overlay; +mod rate_limits_view; mod render; mod resume_picker; mod session_log; diff --git a/codex-rs/tui/src/rate_limits_view.rs b/codex-rs/tui/src/rate_limits_view.rs new file mode 100644 index 0000000000..72fc6e9763 --- /dev/null +++ b/codex-rs/tui/src/rate_limits_view.rs @@ -0,0 +1,504 @@ +use codex_core::protocol::RateLimitSnapshotEvent; +use ratatui::prelude::*; +use ratatui::style::Stylize; + +/// Aggregated output used by the `/limits` command. +/// It contains the rendered summary lines, optional legend, +/// and the precomputed gauge state when one can be shown. +#[derive(Debug)] +pub(crate) struct LimitsView { + pub(crate) summary_lines: Vec>, + pub(crate) legend_lines: Vec>, + grid_state: Option, + grid: GridConfig, +} + +impl LimitsView { + /// Render the gauge for the provided width if the data supports it. + pub(crate) fn gauge_lines(&self, width: u16) -> Vec> { + match self.grid_state { + Some(state) => render_limit_grid(state, self.grid, width), + None => Vec::new(), + } + } +} + +/// Configuration for the simple grid gauge rendered by `/limits`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GridConfig { + pub(crate) weekly_slots: usize, + pub(crate) logo: &'static str, +} + +/// Default gauge configuration used by the TUI. +pub(crate) const DEFAULT_GRID_CONFIG: GridConfig = GridConfig { + weekly_slots: 100, + logo: "(>_)", +}; + +/// Build the lines and optional gauge used by the `/limits` view. +pub(crate) fn build_limits_view( + snapshot: &RateLimitSnapshotEvent, + grid_config: GridConfig, +) -> LimitsView { + let metrics = RateLimitMetrics::from_snapshot(snapshot); + let grid_state = extract_capacity_fraction(snapshot) + .and_then(|fraction| compute_grid_state(&metrics, fraction)) + .map(|state| scale_grid_state(state, grid_config)); + + LimitsView { + summary_lines: build_summary_lines(&metrics), + legend_lines: build_legend_lines(grid_state.is_some()), + grid_state, + grid: grid_config, + } +} + +#[derive(Debug)] +struct RateLimitMetrics { + hourly_used: f64, + weekly_used: f64, + hourly_remaining: f64, + weekly_remaining: f64, + hourly_window_label: String, + weekly_window_label: String, + hourly_reset_hint: String, + weekly_reset_hint: String, +} + +impl RateLimitMetrics { + fn from_snapshot(snapshot: &RateLimitSnapshotEvent) -> Self { + let hourly_used = snapshot.primary_used_percent.clamp(0.0, 100.0); + let weekly_used = snapshot.weekly_used_percent.clamp(0.0, 100.0); + Self { + hourly_used, + weekly_used, + hourly_remaining: (100.0 - hourly_used).max(0.0), + weekly_remaining: (100.0 - weekly_used).max(0.0), + hourly_window_label: format_window_label(Some(snapshot.primary_window_minutes)), + weekly_window_label: format_window_label(Some(snapshot.weekly_window_minutes)), + hourly_reset_hint: format_reset_hint(Some(snapshot.primary_window_minutes)), + weekly_reset_hint: format_reset_hint(Some(snapshot.weekly_window_minutes)), + } + } + + fn hourly_exhausted(&self) -> bool { + self.hourly_remaining <= 0.0 + } + + fn weekly_exhausted(&self) -> bool { + self.weekly_remaining <= 0.0 + } +} + +fn format_window_label(minutes: Option) -> String { + approximate_duration(minutes) + .map(|(value, unit)| format!("≈{value} {} window", pluralize_unit(unit, value))) + .unwrap_or_else(|| "window unknown".to_string()) +} + +fn format_reset_hint(minutes: Option) -> String { + approximate_duration(minutes) + .map(|(value, unit)| format!("≈{value} {}", pluralize_unit(unit, value))) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn approximate_duration(minutes: Option) -> Option<(u64, DurationUnit)> { + let minutes = minutes?; + if minutes == 0 { + return Some((1, DurationUnit::Minute)); + } + if minutes < 60 { + return Some((minutes, DurationUnit::Minute)); + } + if minutes < 1_440 { + let hours = ((minutes as f64) / 60.0).round().max(1.0) as u64; + return Some((hours, DurationUnit::Hour)); + } + let days = ((minutes as f64) / 1_440.0).round().max(1.0) as u64; + if days >= 7 { + let weeks = ((days as f64) / 7.0).round().max(1.0) as u64; + Some((weeks, DurationUnit::Week)) + } else { + Some((days, DurationUnit::Day)) + } +} + +fn pluralize_unit(unit: DurationUnit, value: u64) -> String { + match unit { + DurationUnit::Minute => { + if value == 1 { + "minute".to_string() + } else { + "minutes".to_string() + } + } + DurationUnit::Hour => { + if value == 1 { + "hour".to_string() + } else { + "hours".to_string() + } + } + DurationUnit::Day => { + if value == 1 { + "day".to_string() + } else { + "days".to_string() + } + } + DurationUnit::Week => { + if value == 1 { + "week".to_string() + } else { + "weeks".to_string() + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DurationUnit { + Minute, + Hour, + Day, + Week, +} + +#[derive(Clone, Copy, Debug)] +struct GridState { + weekly_used_ratio: f64, + hourly_remaining_ratio: f64, +} + +fn build_summary_lines(metrics: &RateLimitMetrics) -> Vec> { + let mut lines: Vec> = vec![ + "/limits".magenta().into(), + "".into(), + vec!["Rate limit usage snapshot".bold()].into(), + vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()].into(), + build_usage_line( + " • Hourly limit", + &metrics.hourly_window_label, + metrics.hourly_used, + ), + build_usage_line( + " • Weekly limit", + &metrics.weekly_window_label, + metrics.weekly_used, + ), + ]; + lines.push(build_status_line(metrics)); + lines +} + +fn build_usage_line(label: &str, window_label: &str, used_percent: f64) -> Line<'static> { + Line::from(vec![ + label.to_string().into(), + format!(" ({window_label})").dim(), + ": ".into(), + format!("{used_percent:.1}% used").dark_gray().bold(), + ]) +} + +fn build_status_line(metrics: &RateLimitMetrics) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + if metrics.weekly_exhausted() || metrics.hourly_exhausted() { + spans.push(" Rate limited: ".into()); + let reason = match (metrics.hourly_exhausted(), metrics.weekly_exhausted()) { + (true, true) => "weekly and hourly windows exhausted", + (true, false) => "hourly window exhausted", + (false, true) => "weekly window exhausted", + (false, false) => unreachable!(), + }; + spans.push(reason.red()); + if metrics.hourly_exhausted() { + spans.push(" — hourly resets in ".into()); + spans.push(metrics.hourly_reset_hint.clone().dim()); + } + if metrics.weekly_exhausted() { + spans.push(" — weekly resets in ".into()); + spans.push(metrics.weekly_reset_hint.clone().dim()); + } + } else { + spans.push(" Within current limits".green()); + } + Line::from(spans) +} + +fn build_legend_lines(show_gauge: bool) -> Vec> { + if !show_gauge { + return Vec::new(); + } + vec![ + vec!["Legend".bold()].into(), + vec![ + " • ".into(), + "Dark gray".dark_gray().bold(), + " = weekly usage so far".into(), + ] + .into(), + vec![ + " • ".into(), + "Green".green().bold(), + " = hourly capacity still available".into(), + ] + .into(), + vec![ + " • ".into(), + "Default".bold(), + " = weekly capacity beyond the hourly window".into(), + ] + .into(), + ] +} + +fn extract_capacity_fraction(snapshot: &RateLimitSnapshotEvent) -> Option { + let ratio = snapshot.primary_to_weekly_ratio_percent; + if ratio.is_finite() { + Some((ratio / 100.0).clamp(0.0, 1.0)) + } else { + None + } +} + +fn compute_grid_state(metrics: &RateLimitMetrics, capacity_fraction: f64) -> Option { + if capacity_fraction <= 0.0 { + return None; + } + + let weekly_used_ratio = (metrics.weekly_used / 100.0).clamp(0.0, 1.0); + let weekly_remaining_ratio = (1.0 - weekly_used_ratio).max(0.0); + + let hourly_used_ratio = (metrics.hourly_used / 100.0).clamp(0.0, 1.0); + let hourly_used_within_capacity = + (hourly_used_ratio * capacity_fraction).min(capacity_fraction); + let hourly_remaining_within_capacity = + (capacity_fraction - hourly_used_within_capacity).max(0.0); + + let hourly_remaining_ratio = hourly_remaining_within_capacity.min(weekly_remaining_ratio); + + Some(GridState { + weekly_used_ratio, + hourly_remaining_ratio, + }) +} + +fn scale_grid_state(state: GridState, grid: GridConfig) -> GridState { + if grid.weekly_slots == 0 { + return GridState { + weekly_used_ratio: 0.0, + hourly_remaining_ratio: 0.0, + }; + } + state +} + +/// Convert the grid state to rendered lines for the TUI. +fn render_limit_grid(state: GridState, grid_config: GridConfig, width: u16) -> Vec> { + GridLayout::new(grid_config, width) + .map(|layout| layout.render(state)) + .unwrap_or_default() +} + +/// Precomputed layout information for the usage grid. +struct GridLayout { + size: usize, + inner_width: usize, + config: GridConfig, +} + +impl GridLayout { + const MIN_SIDE: usize = 4; + const MAX_SIDE: usize = 12; + const PREFIX: &'static str = " "; + + fn new(config: GridConfig, width: u16) -> Option { + if config.weekly_slots == 0 || config.logo.is_empty() { + return None; + } + let cell_width = config.logo.chars().count(); + if cell_width == 0 { + return None; + } + + let available_inner = width.saturating_sub((Self::PREFIX.len() + 2) as u16) as usize; + if available_inner == 0 { + return None; + } + + let base_side = (config.weekly_slots as f64) + .sqrt() + .round() + .clamp(1.0, Self::MAX_SIDE as f64) as usize; + let width_limited_side = + ((available_inner + 1) / (cell_width + 1)).clamp(1, Self::MAX_SIDE); + + let mut side = base_side.min(width_limited_side); + if width_limited_side >= Self::MIN_SIDE { + side = side.max(Self::MIN_SIDE.min(width_limited_side)); + } + let side = side.clamp(1, Self::MAX_SIDE); + if side == 0 { + return None; + } + + let inner_width = side * cell_width + side.saturating_sub(1); + Some(Self { + size: side, + inner_width, + config, + }) + } + + /// Render the grid into styled lines for the history cell. + fn render(&self, state: GridState) -> Vec> { + let counts = self.cell_counts(state); + let mut lines = Vec::new(); + lines.push("".into()); + lines.push(self.render_border('╭', '╮')); + + let mut cell_index = 0isize; + for _ in 0..self.size { + let mut spans: Vec> = Vec::new(); + spans.push(Self::PREFIX.into()); + spans.push("│".dim()); + + for col in 0..self.size { + if col > 0 { + spans.push(" ".into()); + } + let span = if cell_index < counts.dark_cells { + self.config.logo.dark_gray() + } else if cell_index < counts.dark_cells + counts.green_cells { + self.config.logo.green() + } else { + self.config.logo.into() + }; + spans.push(span); + cell_index += 1; + } + + spans.push("│".dim()); + lines.push(Line::from(spans)); + } + + lines.push(self.render_border('╰', '╯')); + lines.push("".into()); + + if counts.white_cells == 0 { + lines.push(vec![" (No unused weekly capacity remaining)".dim()].into()); + lines.push("".into()); + } + + lines + } + + fn render_border(&self, left: char, right: char) -> Line<'static> { + let mut text = String::from(Self::PREFIX); + text.push(left); + text.push_str(&"─".repeat(self.inner_width)); + text.push(right); + vec![Span::from(text).dim()].into() + } + + /// Translate usage ratios into the number of coloured cells. + fn cell_counts(&self, state: GridState) -> GridCellCounts { + let total_cells = self.size * self.size; + let mut dark_cells = (state.weekly_used_ratio * total_cells as f64).round() as isize; + dark_cells = dark_cells.clamp(0, total_cells as isize); + let mut green_cells = (state.hourly_remaining_ratio * total_cells as f64).round() as isize; + if dark_cells + green_cells > total_cells as isize { + green_cells = (total_cells as isize - dark_cells).max(0); + } + let white_cells = (total_cells as isize - dark_cells - green_cells).max(0); + + GridCellCounts { + dark_cells, + green_cells, + white_cells, + } + } +} + +/// Number of weekly (dark), hourly (green) and unused (default) cells. +struct GridCellCounts { + dark_cells: isize, + green_cells: isize, + white_cells: isize, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> RateLimitSnapshotEvent { + RateLimitSnapshotEvent { + primary_used_percent: 30.0, + weekly_used_percent: 60.0, + primary_to_weekly_ratio_percent: 40.0, + primary_window_minutes: 300, + weekly_window_minutes: 10_080, + } + } + + #[test] + fn approximate_duration_handles_hours_and_weeks() { + assert_eq!( + approximate_duration(Some(299)), + Some((5, DurationUnit::Hour)) + ); + assert_eq!( + approximate_duration(Some(10_080)), + Some((1, DurationUnit::Week)) + ); + assert_eq!( + approximate_duration(Some(90)), + Some((2, DurationUnit::Hour)) + ); + } + + #[test] + fn build_display_constructs_summary_and_gauge() { + let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); + assert!(display.summary_lines.iter().any(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Weekly limit")) + })); + assert!(display.summary_lines.iter().any(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Hourly limit")) + })); + assert!(!display.gauge_lines(80).is_empty()); + } + + #[test] + fn hourly_and_weekly_percentages_are_not_swapped() { + let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); + let summary = display + .summary_lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(summary.contains("Hourly limit (≈5 hours window): 30.0% used")); + assert!(summary.contains("Weekly limit (≈1 week window): 60.0% used")); + } + + #[test] + fn build_display_without_ratio_skips_gauge() { + let mut s = snapshot(); + s.primary_to_weekly_ratio_percent = f64::NAN; + let display = build_limits_view(&s, DEFAULT_GRID_CONFIG); + assert!(display.gauge_lines(80).is_empty()); + assert!(display.legend_lines.is_empty()); + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 433c0a6d7f..4d4c806727 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -21,6 +21,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Limits, Mcp, Logout, Quit, @@ -40,6 +41,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Limits => "visualize weekly and hourly rate limits", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", @@ -68,6 +70,7 @@ impl SlashCommand { SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Limits | SlashCommand::Mcp | SlashCommand::Quit => true, From 5996ee0e5f75e4f27afd76334db78a33a7fec997 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Sun, 21 Sep 2025 20:18:35 -0700 Subject: [PATCH 25/42] feat: Add more /review options (#3961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the following options: 1. Review current changes 2. Review a specific commit 3. Review against a base branch (PR style) 4. Custom instructions Screenshot 2025-09-20 at 2 11 36 PM --- \+ Adds the following UI helpers: 1. Makes list selection searchable 2. Adds navigation to the bottom pane, so you could add a stack of popups 3. Basic custom prompt view --- codex-rs/core/src/git_info.rs | 174 ++++++++++ codex-rs/tui/src/app.rs | 12 + codex-rs/tui/src/app_event.rs | 14 + .../tui/src/bottom_pane/bottom_pane_view.rs | 11 + .../tui/src/bottom_pane/custom_prompt_view.rs | 254 ++++++++++++++ .../src/bottom_pane/list_selection_view.rs | 309 +++++++++++++----- codex-rs/tui/src/bottom_pane/mod.rs | 44 +-- codex-rs/tui/src/bottom_pane/popup_consts.rs | 3 + .../src/bottom_pane/selection_popup_common.rs | 31 +- codex-rs/tui/src/chatwidget.rs | 260 ++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 233 +++++++++++++ codex-rs/tui/src/slash_command.rs | 2 +- 12 files changed, 1232 insertions(+), 115 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/custom_prompt_view.rs diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index ca62ad499e..832b28f114 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -108,6 +108,61 @@ pub async fn collect_git_info(cwd: &Path) -> Option { Some(git_info) } +/// A minimal commit summary entry used for pickers (subject + timestamp + sha). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommitLogEntry { + pub sha: String, + /// Unix timestamp (seconds since epoch) of the commit time (committer time). + pub timestamp: i64, + /// Single-line subject of the commit message. + pub subject: String, +} + +/// Return the last `limit` commits reachable from HEAD for the current branch. +/// Each entry contains the SHA, commit timestamp (seconds), and subject line. +/// Returns an empty vector if not in a git repo or on error/timeout. +pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec { + // Ensure we're in a git repo first to avoid noisy errors. + let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + + let fmt = "%H%x1f%ct%x1f%s"; // + let n = limit.max(1).to_string(); + let Some(log_out) = + run_git_command_with_timeout(&["log", "-n", &n, &format!("--pretty=format:{fmt}")], cwd) + .await + else { + return Vec::new(); + }; + if !log_out.status.success() { + return Vec::new(); + } + + let text = String::from_utf8_lossy(&log_out.stdout); + let mut entries: Vec = Vec::new(); + for line in text.lines() { + let mut parts = line.split('\u{001f}'); + let sha = parts.next().unwrap_or("").trim(); + let ts_s = parts.next().unwrap_or("").trim(); + let subject = parts.next().unwrap_or("").trim(); + if sha.is_empty() || ts_s.is_empty() { + continue; + } + let timestamp = ts_s.parse::().unwrap_or(0); + entries.push(CommitLogEntry { + sha: sha.to_string(), + timestamp, + subject: subject.to_string(), + }); + } + + entries +} + /// 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 { get_git_repo_root(cwd)?; @@ -202,6 +257,11 @@ async fn get_default_branch(cwd: &Path) -> Option { } // No remote-derived default; try common local defaults if they exist + get_default_branch_local(cwd).await +} + +/// Attempt to determine the repository's default branch name from local branches. +async fn get_default_branch_local(cwd: &Path) -> Option { for candidate in ["main", "master"] { if let Some(verify) = run_git_command_with_timeout( &[ @@ -485,6 +545,46 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { git_dir_path.parent().map(Path::to_path_buf) } +/// Returns a list of local git branches. +/// Includes the default branch at the beginning of the list, if it exists. +pub async fn local_git_branches(cwd: &Path) -> Vec { + let mut branches: Vec = if let Some(out) = + run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await + && out.status.success() + { + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } else { + Vec::new() + }; + + branches.sort_unstable(); + + if let Some(base) = get_default_branch_local(cwd).await + && let Some(pos) = branches.iter().position(|name| name == &base) + { + let base_branch = branches.remove(pos); + branches.insert(0, base_branch); + } + + branches +} + +/// Returns the current checked out branch name. +pub async fn current_branch_name(cwd: &Path) -> Option { + let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?; + if !out.status.success() { + return None; + } + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|name| !name.is_empty()) +} + #[cfg(test)] mod tests { use super::*; @@ -551,6 +651,80 @@ mod tests { repo_path } + #[tokio::test] + async fn test_recent_commits_non_git_directory_returns_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let entries = recent_commits(temp_dir.path(), 10).await; + assert!(entries.is_empty(), "expected no commits outside a git repo"); + } + + #[tokio::test] + async fn test_recent_commits_orders_and_limits() { + use tokio::time::Duration; + use tokio::time::sleep; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Make three distinct commits with small delays to ensure ordering by timestamp. + fs::write(repo_path.join("file.txt"), "one").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "first change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 1"); + + sleep(Duration::from_millis(1100)).await; + + fs::write(repo_path.join("file.txt"), "two").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add 2"); + Command::new("git") + .args(["commit", "-m", "second change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 2"); + + sleep(Duration::from_millis(1100)).await; + + fs::write(repo_path.join("file.txt"), "three").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add 3"); + Command::new("git") + .args(["commit", "-m", "third change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 3"); + + // Request the latest 3 commits; should be our three changes in reverse time order. + let entries = recent_commits(&repo_path, 3).await; + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].subject, "third change"); + assert_eq!(entries[1].subject, "second change"); + assert_eq!(entries[2].subject, "first change"); + // Basic sanity on SHA formatting + for e in entries { + assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit())); + } + } + async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) { let repo_path = create_test_git_repo(temp_dir).await; let remote_path = temp_dir.path().join("remote.git"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f6b735b10b..138d65a052 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -354,6 +354,18 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } + AppEvent::OpenReviewBranchPicker(cwd) => { + self.chat_widget.show_review_branch_picker(&cwd).await; + } + AppEvent::OpenReviewCommitPicker(cwd) => { + self.chat_widget.show_review_commit_picker(&cwd).await; + } + AppEvent::OpenReviewCustomPrompt => { + self.chat_widget.show_review_custom_prompt(); + } + AppEvent::OpenReviewPopup => { + self.chat_widget.open_review_popup(); + } } Ok(true) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 41992cddcc..52e9393d2d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; @@ -65,4 +67,16 @@ pub(crate) enum AppEvent { /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), + + /// Open the branch picker option from the review popup. + OpenReviewBranchPicker(PathBuf), + + /// Open the commit picker option from the review popup. + OpenReviewCommitPicker(PathBuf), + + /// Open the custom prompt option from the review popup. + OpenReviewCustomPrompt, + + /// Open the top-level review presets popup. + OpenReviewPopup, } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 794dd8c422..de1beaa278 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -28,6 +28,17 @@ pub(crate) trait BottomPaneView { /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer); + /// Optional paste handler. Return true if the view modified its state and + /// needs a redraw. + fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool { + false + } + + /// Cursor position when this view is active. + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + None + } + /// Try to handle approval request; return the original value if not /// consumed. fn try_consume_approval_request( diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 0000000000..4bad707724 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -0,0 +1,254 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::cell::RefCell; + +use super::popup_consts::STANDARD_POPUP_HINT_LINE; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::SelectionAction; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +/// Callback invoked when the user submits a custom prompt. +pub(crate) type PromptSubmitted = Box; + +/// Minimal multi-line text input view to collect custom review instructions. +pub(crate) struct CustomPromptView { + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + app_event_tx: AppEventSender, + on_escape: Option, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl CustomPromptView { + pub(crate) fn new( + title: String, + placeholder: String, + context_label: Option, + app_event_tx: AppEventSender, + on_escape: Option, + on_submit: PromptSubmitted, + ) -> Self { + Self { + title, + placeholder, + context_label, + on_submit, + app_event_tx, + on_escape, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } + } +} + +impl BottomPaneView for CustomPromptView { + fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(_pane); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let text = self.textarea.text().trim().to_string(); + if !text.is_empty() { + (self.on_submit)(text); + self.complete = true; + } + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.textarea.input(key_event); + } + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> CancellationEvent { + self.complete = true; + if let Some(cb) = &self.on_escape { + cb(&self.app_event_tx); + } + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn desired_height(&self, width: u16) -> u16 { + let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; + 1u16 + extra_top + self.input_height(width) + 3u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let input_height = self.input_height(area.width); + + // Title line + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + let title_spans: Vec> = vec![gutter(), self.title.clone().bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Optional context line + let mut input_y = area.y.saturating_add(1); + if let Some(context_label) = &self.context_label { + let context_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: 1, + }; + let spans: Vec> = vec![gutter(), context_label.clone().cyan()]; + Paragraph::new(Line::from(spans)).render(context_area, buf); + input_y = input_y.saturating_add(1); + } + + // Input line + let input_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(self.placeholder.clone().dim())) + .render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(STANDARD_POPUP_HINT_LINE).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; + let top_line_count = 1u16 + extra_offset; + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, &state) + } +} + +impl CustomPromptView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} 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 5d5dbf0f33..82466bf796 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -28,6 +28,19 @@ pub(crate) struct SelectionItem { pub description: Option, pub is_current: bool, pub actions: Vec, + pub dismiss_on_select: bool, + pub search_value: Option, +} + +#[derive(Default)] +pub(crate) struct SelectionViewParams { + pub title: String, + pub subtitle: Option, + pub footer_hint: Option, + pub items: Vec, + pub is_searchable: bool, + pub search_placeholder: Option, + pub on_escape: Option, } pub(crate) struct ListSelectionView { @@ -38,6 +51,11 @@ pub(crate) struct ListSelectionView { state: ScrollState, complete: bool, app_event_tx: AppEventSender, + on_escape: Option, + is_searchable: bool, + search_query: String, + search_placeholder: Option, + filtered_indices: Vec, } impl ListSelectionView { @@ -49,49 +67,145 @@ impl ListSelectionView { let para = Paragraph::new(Line::from(Self::dim_prefix_span())); para.render(area, buf); } - pub fn new( - title: String, - subtitle: Option, - footer_hint: Option, - items: Vec, - app_event_tx: AppEventSender, - ) -> Self { + + pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { let mut s = Self { - title, - subtitle, - footer_hint, - items, + title: params.title, + subtitle: params.subtitle, + footer_hint: params.footer_hint, + items: params.items, state: ScrollState::new(), complete: false, app_event_tx, + on_escape: params.on_escape, + is_searchable: params.is_searchable, + search_query: String::new(), + search_placeholder: if params.is_searchable { + params.search_placeholder + } else { + None + }, + filtered_indices: Vec::new(), }; - let len = s.items.len(); - if let Some(idx) = s.items.iter().position(|it| it.is_current) { - s.state.selected_idx = Some(idx); - } - s.state.clamp_selection(len); - s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + s.apply_filter(); s } + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + fn apply_filter(&mut self) { + let previously_selected = self + .state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) + .or_else(|| { + (!self.is_searchable) + .then(|| self.items.iter().position(|item| item.is_current)) + .flatten() + }); + + if self.is_searchable && !self.search_query.is_empty() { + let query_lower = self.search_query.to_lowercase(); + self.filtered_indices = self + .items + .iter() + .enumerate() + .filter_map(|(idx, item)| { + let matches = if let Some(search_value) = &item.search_value { + search_value.to_lowercase().contains(&query_lower) + } else { + let mut matches = item.name.to_lowercase().contains(&query_lower); + if !matches && let Some(desc) = &item.description { + matches = desc.to_lowercase().contains(&query_lower); + } + matches + }; + matches.then_some(idx) + }) + .collect(); + } else { + self.filtered_indices = (0..self.items.len()).collect(); + } + + let len = self.filtered_indices.len(); + self.state.selected_idx = self + .state + .selected_idx + .and_then(|visible_idx| { + self.filtered_indices + .get(visible_idx) + .and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx)) + }) + .or_else(|| { + previously_selected.and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + }) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + } + + fn build_rows(&self) -> Vec { + self.filtered_indices + .iter() + .enumerate() + .filter_map(|(visible_idx, actual_idx)| { + self.items.get(*actual_idx).map(|item| { + let is_selected = self.state.selected_idx == Some(visible_idx); + let prefix = if is_selected { '>' } else { ' ' }; + let name = item.name.as_str(); + let name_with_marker = if item.is_current { + format!("{name} (current)") + } else { + item.name.clone() + }; + let n = visible_idx + 1; + let display_name = format!("{prefix} {n}. {name_with_marker}"); + GenericDisplayRow { + name: display_name, + match_indices: None, + is_current: item.is_current, + description: item.description.clone(), + } + }) + }) + .collect() + } + fn move_up(&mut self) { - let len = self.items.len(); + let len = self.visible_len(); self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); } fn move_down(&mut self) { - let len = self.items.len(); + let len = self.visible_len(); self.state.move_down_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); } fn accept(&mut self) { - if let Some(idx) = self.state.selected_idx { - if let Some(item) = self.items.get(idx) { - for act in &item.actions { - act(&self.app_event_tx); - } + if let Some(idx) = self.state.selected_idx + && let Some(actual_idx) = self.filtered_indices.get(idx) + && let Some(item) = self.items.get(*actual_idx) + { + for act in &item.actions { + act(&self.app_event_tx); + } + if item.dismiss_on_select { self.complete = true; } } else { @@ -99,9 +213,10 @@ impl ListSelectionView { } } - fn cancel(&mut self) { - // Close the popup without performing any actions. - self.complete = true; + #[cfg(test)] + pub(crate) fn set_search_query(&mut self, query: String) { + self.search_query = query; + self.apply_filter(); } } @@ -115,9 +230,29 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Down, .. } => self.move_down(), + KeyEvent { + code: KeyCode::Backspace, + .. + } if self.is_searchable => { + self.search_query.pop(); + self.apply_filter(); + } KeyEvent { code: KeyCode::Esc, .. - } => self.cancel(), + } => { + self.on_ctrl_c(_pane); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -133,39 +268,25 @@ impl BottomPaneView for ListSelectionView { fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.complete = true; + if let Some(cb) = &self.on_escape { + cb(&self.app_event_tx); + } CancellationEvent::Handled } fn desired_height(&self, width: u16) -> u16 { // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. // Build the same display rows used by the renderer so wrapping math matches. - let rows: Vec = self - .items - .iter() - .enumerate() - .map(|(i, it)| { - let is_selected = self.state.selected_idx == Some(i); - let prefix = if is_selected { '>' } else { ' ' }; - let name_with_marker = if it.is_current { - format!("{} (current)", it.name) - } else { - it.name.clone() - }; - let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker); - GenericDisplayRow { - name: display_name, - match_indices: None, - is_current: it.is_current, - description: it.description.clone(), - } - }) - .collect(); + let rows = self.build_rows(); let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width); // +1 for the title row, +1 for a spacer line beneath the header, // +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing) let mut height = rows_height + 2; + if self.is_searchable { + height = height.saturating_add(1); + } if self.subtitle.is_some() { // +1 for subtitle (the spacer is accounted for above) height = height.saturating_add(1); @@ -194,6 +315,25 @@ impl BottomPaneView for ListSelectionView { title_para.render(title_area, buf); let mut next_y = area.y.saturating_add(1); + if self.is_searchable { + let search_area = Rect { + x: area.x, + y: next_y, + width: area.width, + height: 1, + }; + let query_span: Span<'static> = if self.search_query.is_empty() { + self.search_placeholder + .as_ref() + .map(|placeholder| placeholder.clone().dim()) + .unwrap_or_else(|| "".into()) + } else { + self.search_query.clone().into() + }; + Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span])) + .render(search_area, buf); + next_y = next_y.saturating_add(1); + } if let Some(sub) = &self.subtitle { let subtitle_area = Rect { x: area.x, @@ -228,27 +368,7 @@ impl BottomPaneView for ListSelectionView { .saturating_sub(footer_reserved), }; - let rows: Vec = self - .items - .iter() - .enumerate() - .map(|(i, it)| { - let is_selected = self.state.selected_idx == Some(i); - let prefix = if is_selected { '>' } else { ' ' }; - let name_with_marker = if it.is_current { - format!("{} (current)", it.name) - } else { - it.name.clone() - }; - let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker); - GenericDisplayRow { - name: display_name, - match_indices: None, - is_current: it.is_current, - description: it.description.clone(), - } - }) - .collect(); + let rows = self.build_rows(); if rows_area.height > 0 { render_rows( rows_area, @@ -279,6 +399,7 @@ mod tests { use super::BottomPaneView; use super::*; use crate::app_event::AppEvent; + use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use insta::assert_snapshot; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; @@ -292,19 +413,26 @@ mod tests { description: Some("Codex can read files".to_string()), is_current: true, actions: vec![], + dismiss_on_select: true, + search_value: None, }, SelectionItem { name: "Full Access".to_string(), description: Some("Codex can edit files".to_string()), is_current: false, actions: vec![], + dismiss_on_select: true, + search_value: None, }, ]; ListSelectionView::new( - "Select Approval Mode".to_string(), - subtitle.map(str::to_string), - Some("Press Enter to confirm or Esc to go back".to_string()), - items, + SelectionViewParams { + title: "Select Approval Mode".to_string(), + subtitle: subtitle.map(str::to_string), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + ..Default::default() + }, tx, ) } @@ -347,4 +475,33 @@ mod tests { let view = make_selection_view(Some("Switch between Codex approval presets")); assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + + #[test] + fn renders_search_query_line_when_enabled() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: false, + actions: vec![], + dismiss_on_select: true, + search_value: None, + }]; + let mut view = ListSelectionView::new( + SelectionViewParams { + title: "Select Approval Mode".to_string(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }, + tx, + ); + view.set_search_query("filters".to_string()); + + let lines = render_lines(&view); + assert!(lines.contains("▌ filters")); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f30bd418e9..06eff112bc 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -20,10 +20,12 @@ mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +pub mod custom_prompt_view; mod file_search_popup; mod list_selection_view; +pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; -mod popup_consts; +pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; @@ -148,10 +150,10 @@ impl BottomPane { // status indicator shown while a task is running, or approval modal). // In these states the textarea is not interactable, so we should not // show its caret. - if self.active_view.is_some() { - None + let [_, content] = self.layout(area); + if let Some(view) = self.active_view.as_ref() { + view.cursor_pos(content) } else { - let [_, content] = self.layout(area); self.composer.cursor_pos(content) } } @@ -224,7 +226,17 @@ impl BottomPane { } pub fn handle_paste(&mut self, pasted: String) { - if self.active_view.is_none() { + if let Some(mut view) = self.active_view.take() { + let needs_redraw = view.handle_paste(self, pasted); + if view.is_complete() { + self.on_active_view_complete(); + } else { + self.active_view = Some(view); + } + if needs_redraw { + self.request_redraw(); + } + } else { let needs_redraw = self.composer.handle_paste(pasted); if needs_redraw { self.request_redraw(); @@ -318,22 +330,9 @@ impl BottomPane { } /// Show a generic list selection view with the provided items. - pub(crate) fn show_selection_view( - &mut self, - title: String, - subtitle: Option, - footer_hint: Option, - items: Vec, - ) { - let view = list_selection_view::ListSelectionView::new( - title, - subtitle, - footer_hint, - items, - self.app_event_tx.clone(), - ); + pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { + let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); self.active_view = Some(Box::new(view)); - self.request_redraw(); } /// Update the queued messages shown under the status header. @@ -373,6 +372,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn show_view(&mut self, view: Box) { + self.active_view = Some(view); + self.request_redraw(); + } + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest) { let request = if let Some(view) = self.active_view.as_mut() { diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 5f447d735c..5147b2ee5f 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -3,3 +3,6 @@ /// Maximum number of rows any popup should attempt to display. /// Keep this consistent across all popups for a uniform feel. pub(crate) const MAX_POPUP_ROWS: usize = 8; + +/// Standard footer hint text used by popups. +pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back"; 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 684924a44a..38eaab7486 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -13,6 +13,7 @@ use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar; use super::scroll_state::ScrollState; use crate::ui_consts::LIVE_PREFIX_COLS; @@ -55,10 +56,24 @@ fn compute_desc_col( /// at `desc_col`. Applies fuzzy-match bolding when indices are present and /// dims the description. fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { + // Enforce single-line name: allow at most desc_col - 2 cells for name, + // reserving two spaces before the description column. + let name_limit = desc_col.saturating_sub(2); + let mut name_spans: Vec = Vec::with_capacity(row.name.len()); + let mut used_width = 0usize; + let mut truncated = false; + if let Some(idxs) = row.match_indices.as_ref() { let mut idx_iter = idxs.iter().peekable(); for (char_idx, ch) in row.name.chars().enumerate() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if used_width + ch_w > name_limit { + truncated = true; + break; + } + used_width += ch_w; + if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); name_spans.push(ch.to_string().bold()); @@ -67,7 +82,21 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { } } } else { - name_spans.push(row.name.clone().into()); + for ch in row.name.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if used_width + ch_w > name_limit { + truncated = true; + break; + } + used_width += ch_w; + name_spans.push(ch.to_string().into()); + } + } + + if truncated { + // If there is at least one cell available, add an ellipsis. + // When name_limit is 0, we still show an ellipsis to indicate truncation. + name_spans.push("…".into()); } let this_name_width = Line::from(name_spans.clone()).width(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cb8acd04fb..b4204f3a37 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use codex_core::config::Config; use codex_core::config_types::Notifications; +use codex_core::git_info::current_branch_name; +use codex_core::git_info::local_git_branches; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -63,6 +65,9 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; @@ -87,6 +92,7 @@ mod session_header; use self::session_header::SessionHeader; use crate::streaming::controller::AppEventHistorySink; use crate::streaming::controller::StreamController; +// use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; use codex_common::model_presets::ModelPreset; @@ -97,6 +103,7 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; +use std::path::Path; // Track information about an in-flight exec command. struct RunningCommand { @@ -941,13 +948,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { - // Simplified flow: directly send a review op for current changes. - self.submit_op(Op::Review { - review_request: ReviewRequest { - prompt: "review current changes".to_string(), - user_facing_hint: "current changes".to_string(), - }, - }); + self.open_review_popup(); } SlashCommand::Model => { self.open_model_popup(); @@ -1417,15 +1418,20 @@ impl ChatWidget { description, is_current, actions, + dismiss_on_select: true, + search_value: None, }); } - self.bottom_pane.show_selection_view( - "Select model and reasoning level".to_string(), - Some("Switch between OpenAI models for this and future Codex CLI session".to_string()), - Some("Press Enter to confirm or Esc to go back".to_string()), + self.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select model and reasoning level".to_string(), + subtitle: Some( + "Switch between OpenAI models for this and future Codex CLI session".to_string(), + ), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), items, - ); + ..Default::default() + }); } /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). @@ -1458,15 +1464,17 @@ impl ChatWidget { description, is_current, actions, + dismiss_on_select: true, + search_value: None, }); } - self.bottom_pane.show_selection_view( - "Select Approval Mode".to_string(), - None, - Some("Press Enter to confirm or Esc to go back".to_string()), + self.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select Approval Mode".to_string(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), items, - ); + ..Default::default() + }); } /// Set the approval policy in the widget's config copy. @@ -1575,6 +1583,181 @@ impl ChatWidget { self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + pub(crate) fn open_review_popup(&mut self) { + let mut items: Vec = Vec::new(); + + items.push(SelectionItem { + name: "Review uncommitted changes".to_string(), + description: None, + is_current: false, + actions: vec![Box::new( + move |tx: &AppEventSender| { + tx.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(), + user_facing_hint: "current changes".to_string(), + }, + })); + }, + )], + dismiss_on_select: true, + search_value: None, + }); + + // New: Review a specific commit (opens commit picker) + items.push(SelectionItem { + name: "Review a commit".to_string(), + description: None, + is_current: false, + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + search_value: None, + }); + + items.push(SelectionItem { + name: "Review against a base branch".to_string(), + description: None, + is_current: false, + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + search_value: None, + }); + + items.push(SelectionItem { + name: "Custom review instructions".to_string(), + description: None, + is_current: false, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReviewCustomPrompt); + })], + dismiss_on_select: false, + search_value: None, + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select a review preset".into(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + on_escape: None, + ..Default::default() + }); + } + + pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { + let branches = local_git_branches(cwd).await; + let current_branch = current_branch_name(cwd) + .await + .unwrap_or_else(|| "(detached HEAD)".to_string()); + let mut items: Vec = Vec::with_capacity(branches.len()); + + for option in branches { + let branch = option.clone(); + items.push(SelectionItem { + name: format!("{current_branch} -> {branch}"), + description: None, + is_current: false, + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + prompt: format!( + "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings." + ), + user_facing_hint: format!("changes against '{branch}'"), + }, + })); + })], + dismiss_on_select: true, + search_value: Some(option), + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select a base branch".to_string(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), + ..Default::default() + }); + } + + pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { + let commits = codex_core::git_info::recent_commits(cwd, 100).await; + + let mut items: Vec = Vec::with_capacity(commits.len()); + for entry in commits { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let short = sha.chars().take(7).collect::(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + description: None, + is_current: false, + actions: vec![Box::new(move |tx3: &AppEventSender| { + let hint = format!("commit {short}"); + let prompt = format!( + "Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings." + ); + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + prompt, + user_facing_hint: hint, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select a commit to review".to_string(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), + ..Default::default() + }); + } + + pub(crate) fn show_review_custom_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Custom review instructions".to_string(), + "Type instructions and press Enter".to_string(), + None, + self.app_event_tx.clone(), + Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), + Box::new(move |prompt: String| { + let trimmed = prompt.trim().to_string(); + if trimmed.is_empty() { + return; + } + tx.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + prompt: trimmed.clone(), + user_facing_hint: trimmed, + }, + })); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. @@ -1731,5 +1914,48 @@ fn extract_first_bold(s: &str) -> Option { None } +#[cfg(test)] +pub(crate) fn show_review_commit_picker_with_entries( + chat: &mut ChatWidget, + entries: Vec, +) { + let mut items: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let short = sha.chars().take(7).collect::(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + description: None, + is_current: false, + actions: vec![Box::new(move |tx3: &AppEventSender| { + let hint = format!("commit {short}"); + let prompt = format!( + "Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings." + ); + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + prompt, + user_facing_hint: hint, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + }); + } + + chat.bottom_pane.show_selection_view(SelectionViewParams { + title: "Select a commit to review".to_string(), + footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..Default::default() + }); +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7427bb4414..c009cabd94 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -801,6 +801,135 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +/// Selecting the custom prompt option from the review popup sends +/// OpenReviewCustomPrompt to the app event channel. +#[test] +fn review_popup_custom_prompt_action_sends_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + // Open the preset selection popup + chat.open_review_popup(); + + // Move selection down to the fourth item: "Custom review instructions" + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + // Activate + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Drain events and ensure we saw the OpenReviewCustomPrompt request + let mut found = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::OpenReviewCustomPrompt = ev { + found = true; + break; + } + } + assert!(found, "expected OpenReviewCustomPrompt event to be sent"); +} + +/// The commit picker shows only commit subjects (no timestamps). +#[test] +fn review_commit_picker_shows_subjects_without_timestamps() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Show commit picker with synthetic entries. + let entries = vec![ + codex_core::git_info::CommitLogEntry { + sha: "1111111deadbeef".to_string(), + timestamp: 0, + subject: "Add new feature X".to_string(), + }, + codex_core::git_info::CommitLogEntry { + sha: "2222222cafebabe".to_string(), + timestamp: 0, + subject: "Fix bug Y".to_string(), + }, + ]; + super::show_review_commit_picker_with_entries(&mut chat, entries); + + // Render the bottom pane and inspect the lines for subjects and absence of time words. + let width = 72; + let height = chat.desired_height(width); + let area = ratatui::layout::Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + (&chat).render_ref(area, &mut buf); + + let mut blob = String::new(); + for y in 0..area.height { + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + blob.push(' '); + } else { + blob.push_str(s); + } + } + blob.push('\n'); + } + + assert!( + blob.contains("Add new feature X"), + "expected subject in output" + ); + assert!(blob.contains("Fix bug Y"), "expected subject in output"); + + // Ensure no relative-time phrasing is present. + let lowered = blob.to_lowercase(); + assert!( + !lowered.contains("ago") + && !lowered.contains(" second") + && !lowered.contains(" minute") + && !lowered.contains(" hour") + && !lowered.contains(" day"), + "expected no relative time in commit picker output: {blob:?}" + ); +} + +/// Submitting the custom prompt view sends Op::Review with the typed prompt +/// and uses the same text for the user-facing hint. +#[test] +fn custom_prompt_submit_sends_review_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.show_review_custom_prompt(); + // Paste prompt text via ChatWidget handler, then submit + chat.handle_paste(" please audit dependencies ".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + let evt = rx.try_recv().expect("expected one app event"); + match evt { + AppEvent::CodexOp(Op::Review { review_request }) => { + assert_eq!( + review_request.prompt, + "please audit dependencies".to_string() + ); + assert_eq!( + review_request.user_facing_hint, + "please audit dependencies".to_string() + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +/// Hitting Enter on an empty custom prompt view does not submit. +#[test] +fn custom_prompt_enter_empty_does_not_send() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.show_review_custom_prompt(); + // Enter without any text + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // No AppEvent::CodexOp should be sent + assert!(rx.try_recv().is_err(), "no app event should be sent"); +} + // Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ // marker (replacing the spinner) and flushes it into history. #[test] @@ -830,6 +959,110 @@ fn interrupt_exec_marks_failed_snapshot() { assert_snapshot!("interrupt_exec_marks_failed", exec_blob); } +/// Opening custom prompt from the review popup, pressing Esc returns to the +/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +#[test] +fn review_custom_prompt_escape_navigates_back_then_dismisses() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Open the custom prompt submenu (child view) directly. + chat.show_review_custom_prompt(); + + // Verify child view is on top. + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Custom review instructions"), + "expected custom prompt view header: {header:?}" + ); + + // Esc once: child view closes, parent (review presets) remains. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + // Process emitted app events to reopen the parent review popup. + while let Ok(ev) = rx.try_recv() { + if let AppEvent::OpenReviewPopup = ev { + chat.open_review_popup(); + break; + } + } + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a review preset"), + "expected to return to parent review popup: {header:?}" + ); + + // Esc again: parent closes; back to normal composer state. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + chat.is_normal_backtrack_mode(), + "expected to be back in normal composer mode" + ); +} + +/// Opening base-branch picker from the review popup, pressing Esc returns to the +/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +#[tokio::test(flavor = "current_thread")] +async fn review_branch_picker_escape_navigates_back_then_dismisses() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine. + let cwd = std::env::temp_dir(); + chat.show_review_branch_picker(&cwd).await; + + // Verify child view header. + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a base branch"), + "expected branch picker header: {header:?}" + ); + + // Esc once: child view closes, parent remains. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + // Process emitted app events to reopen the parent review popup. + while let Ok(ev) = rx.try_recv() { + if let AppEvent::OpenReviewPopup = ev { + chat.open_review_popup(); + break; + } + } + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a review preset"), + "expected to return to parent review popup: {header:?}" + ); + + // Esc again: parent closes; back to normal composer state. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + chat.is_normal_backtrack_mode(), + "expected to be back in normal composer mode" + ); +} + +fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { + let height = chat.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + (chat).render_ref(area, &mut buf); + let mut row = String::new(); + // Row 0 is the top spacer for the bottom pane; row 1 contains the header line + let y = 1u16.min(height.saturating_sub(1)); + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + row.push(' '); + } else { + row.push_str(s); + } + } + row +} + #[test] fn exec_history_extends_previous_when_consecutive() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4d4c806727..6570cf685c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -36,7 +36,7 @@ impl SlashCommand { SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Review => "review my changes and find issues", SlashCommand::Quit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", From 14a115d4886a44b085ea4a9151329bd91c2021e7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 22 Sep 2025 07:50:41 -0700 Subject: [PATCH 26/42] Add non_sandbox_test helper (#3880) Makes tests shorter --- codex-rs/Cargo.lock | 2 + codex-rs/core/tests/common/lib.rs | 18 +++ codex-rs/core/tests/suite/cli_stream.rs | 30 +---- codex-rs/core/tests/suite/client.rs | 44 ++------ codex-rs/core/tests/suite/compact.rs | 106 +++++++----------- codex-rs/core/tests/suite/review.rs | 44 ++------ .../suite/stream_error_allows_next_turn.rs | 9 +- .../core/tests/suite/stream_no_completed.rs | 9 +- codex-rs/exec/tests/suite/apply_patch.rs | 18 +-- codex-rs/login/Cargo.toml | 1 + .../login/tests/suite/login_server_e2e.rs | 23 +--- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/mcp-server/tests/suite/codex_tool.rs | 8 +- codex-rs/mcp-server/tests/suite/interrupt.rs | 9 +- 14 files changed, 99 insertions(+), 223 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 73c1117af1..653e205b2e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -797,6 +797,7 @@ dependencies = [ "chrono", "codex-core", "codex-protocol", + "core_test_support", "rand 0.8.5", "reqwest", "serde", @@ -835,6 +836,7 @@ dependencies = [ "codex-core", "codex-login", "codex-protocol", + "core_test_support", "mcp-types", "mcp_test_support", "os_info", diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 95af79b220..9b86694e00 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -126,3 +126,21 @@ where } } } + +#[macro_export] +macro_rules! non_sandbox_test { + // For tests that return () + () => {{ + if ::std::env::var("CODEX_SANDBOX_NETWORK_DISABLED").is_ok() { + println!("Skipping test because it cannot execute when network is disabled in a Codex sandbox."); + return; + } + }}; + // For tests that return Result<(), _> + (result $(,)?) => {{ + if ::std::env::var("CODEX_SANDBOX_NETWORK_DISABLED").is_ok() { + println!("Skipping test because it cannot execute when network is disabled in a Codex sandbox."); + return ::core::result::Result::Ok(()); + } + }}; +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 1a0c016272..368c47dc68 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,7 +1,7 @@ use assert_cmd::Command as AssertCommand; use codex_core::RolloutRecorder; use codex_core::protocol::GitInfo; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use core_test_support::non_sandbox_test; use std::time::Duration; use std::time::Instant; use tempfile::TempDir; @@ -21,12 +21,7 @@ use wiremock::matchers::path; /// 4. Ensures the response is received exactly once and contains "hi" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn chat_mode_stream_cli() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = MockServer::start().await; let sse = concat!( @@ -102,12 +97,7 @@ async fn chat_mode_stream_cli() { /// received by a mock OpenAI Responses endpoint. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_cli_applies_experimental_instructions_file() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Start mock server which will capture the request and return a minimal // SSE stream for a single turn. @@ -195,12 +185,7 @@ async fn exec_cli_applies_experimental_instructions_file() { /// 4. Ensures the fixture content is correctly streamed through the CLI #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_api_stream_cli() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse"); @@ -232,12 +217,7 @@ async fn responses_api_stream_cli() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn integration_creates_and_checks_session_file() { // Honor sandbox network restrictions for CI parity with the other tests. - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // 1. Temp home so we read/write isolated session files. let home = TempDir::new().unwrap(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d0ae608cb0..002a4b1f9d 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,12 +16,12 @@ use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::non_sandbox_test; use core_test_support::responses; use core_test_support::wait_for_event; use futures::StreamExt; @@ -126,12 +126,7 @@ fn write_auth_json( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn resume_includes_initial_messages_and_sends_prior_items() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Create a fake rollout session file with prior user + system + assistant messages. let tmpdir = TempDir::new().unwrap(); @@ -297,12 +292,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_conversation_id_and_model_headers_in_request() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Mock server let server = MockServer::start().await; @@ -427,12 +417,7 @@ async fn includes_base_instructions_override_in_request() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn chatgpt_auth_sends_correct_request() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Mock server let server = MockServer::start().await; @@ -506,12 +491,7 @@ async fn chatgpt_auth_sends_correct_request() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Mock server let server = MockServer::start().await; @@ -638,12 +618,7 @@ async fn includes_user_instructions_message_in_request() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_responses_request_includes_store_and_reasoning_ids() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = MockServer::start().await; @@ -1036,12 +1011,7 @@ fn create_dummy_codex_auth() -> CodexAuth { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn history_dedupes_streamed_and_final_messages_across_turns() { // Skip under Codex sandbox network restrictions (mirrors other tests). - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Mock server that will receive three sequential requests and return the same SSE stream // each time: a few deltas, then a final assistant message, then completed. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index a58de304fb..3cae9841c5 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -9,9 +9,7 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; -use core_test_support::responses; use core_test_support::wait_for_event; use tempfile::TempDir; use wiremock::Mock; @@ -21,11 +19,16 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +use core_test_support::non_sandbox_test; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use responses::ev_assistant_message; -use responses::ev_completed; -use responses::sse; -use responses::start_mock_server; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; @@ -50,12 +53,7 @@ const DUMMY_CALL_ID: &str = "call-multi-auto"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Set up a mock server that we can inspect after the run. let server = start_mock_server().await; @@ -81,19 +79,19 @@ async fn summarize_context_three_requests_and_instructions() { body.contains("\"text\":\"hello world\"") && !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - responses::mount_sse_once(&server, first_matcher, sse1).await; + mount_sse_once(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - responses::mount_sse_once(&server, second_matcher, sse2).await; + mount_sse_once(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - responses::mount_sse_once(&server, third_matcher, sse3).await; + mount_sse_once(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { @@ -276,28 +274,23 @@ async fn summarize_context_three_requests_and_instructions() { #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn auto_compact_runs_after_token_limit_hit() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = start_mock_server().await; let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - responses::ev_completed_with_tokens("r1", 70_000), + ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - responses::ev_completed_with_tokens("r2", 330_000), + ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - responses::ev_completed_with_tokens("r3", 200), + ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -309,7 +302,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(responses::sse_response(sse1)) + .respond_with(sse_response(sse1)) .mount(&server) .await; @@ -322,7 +315,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(responses::sse_response(sse2)) + .respond_with(sse_response(sse2)) .mount(&server) .await; @@ -333,7 +326,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(responses::sse_response(sse3)) + .respond_with(sse_response(sse3)) .mount(&server) .await; @@ -417,28 +410,23 @@ async fn auto_compact_runs_after_token_limit_hit() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_persists_rollout_entries() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = start_mock_server().await; let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - responses::ev_completed_with_tokens("r1", 70_000), + ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - responses::ev_completed_with_tokens("r2", 330_000), + ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - responses::ev_completed_with_tokens("r3", 200), + ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -450,7 +438,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(responses::sse_response(sse1)) + .respond_with(sse_response(sse1)) .mount(&server) .await; @@ -463,7 +451,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(responses::sse_response(sse2)) + .respond_with(sse_response(sse2)) .mount(&server) .await; @@ -474,7 +462,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(responses::sse_response(sse3)) + .respond_with(sse_response(sse3)) .mount(&server) .await; @@ -550,28 +538,23 @@ async fn auto_compact_persists_rollout_entries() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_stops_after_failed_attempt() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = start_mock_server().await; let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - responses::ev_completed_with_tokens("r1", 500), + ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", SUMMARY_TEXT), - responses::ev_completed_with_tokens("r2", 50), + ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ ev_assistant_message("m3", STILL_TOO_BIG_REPLY), - responses::ev_completed_with_tokens("r3", 500), + ev_completed_with_tokens("r3", 500), ]); let first_matcher = |req: &wiremock::Request| { @@ -582,7 +565,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(responses::sse_response(sse1.clone())) + .respond_with(sse_response(sse1.clone())) .mount(&server) .await; @@ -593,7 +576,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(responses::sse_response(sse2.clone())) + .respond_with(sse_response(sse2.clone())) .mount(&server) .await; @@ -605,7 +588,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(responses::sse_response(sse3.clone())) + .respond_with(sse_response(sse3.clone())) .mount(&server) .await; @@ -664,38 +647,33 @@ async fn auto_compact_stops_after_failed_attempt() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_events() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = start_mock_server().await; let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - responses::ev_completed_with_tokens("r1", 500), + ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", FIRST_AUTO_SUMMARY), - responses::ev_completed_with_tokens("r2", 50), + ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ - responses::ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - responses::ev_completed_with_tokens("r3", 150), + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r3", 150), ]); let sse4 = sse(vec![ ev_assistant_message("m4", SECOND_LARGE_REPLY), - responses::ev_completed_with_tokens("r4", 450), + ev_completed_with_tokens("r4", 450), ]); let sse5 = sse(vec![ ev_assistant_message("m5", SECOND_AUTO_SUMMARY), - responses::ev_completed_with_tokens("r5", 60), + ev_completed_with_tokens("r5", 60), ]); let sse6 = sse(vec![ ev_assistant_message("m6", FINAL_REPLY), - responses::ev_completed_with_tokens("r6", 120), + ev_completed_with_tokens("r6", 120), ]); #[derive(Clone)] diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index a20807e4ec..d511946aaf 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -20,9 +20,9 @@ use codex_core::protocol::ReviewOutputEvent; use codex_core::protocol::ReviewRequest; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::non_sandbox_test; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -42,12 +42,7 @@ use wiremock::matchers::path; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_op_emits_lifecycle_and_review_output() { // Skip under Codex sandbox network restrictions. - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Start mock Responses API server. Return a single assistant message whose // text is a JSON-encoded ReviewOutputEvent. @@ -172,12 +167,7 @@ async fn review_op_emits_lifecycle_and_review_output() { #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_op_with_plain_text_emits_review_fallback() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let sse_raw = r#"[ {"type":"response.output_item.done", "item":{ @@ -226,12 +216,7 @@ async fn review_op_with_plain_text_emits_review_fallback() { #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_does_not_emit_agent_message_on_structured_output() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let review_json = serde_json::json!({ "findings": [ @@ -303,12 +288,7 @@ async fn review_does_not_emit_agent_message_on_structured_output() { /// request uses that model (and not the main chat model). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_custom_review_model_from_config() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Minimal stream: just a completed event let sse_raw = r#"[ @@ -361,12 +341,7 @@ async fn review_uses_custom_review_model_from_config() { #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_input_isolated_from_parent_history() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Mock server for the single review request let sse_raw = r#"[ @@ -542,12 +517,7 @@ async fn review_input_isolated_from_parent_history() { /// messages in its request `input`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_history_does_not_leak_into_parent_session() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Respond to both the review request and the subsequent parent request. let sse_raw = r#"[ 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 3395eef954..f41534ab75 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 @@ -7,9 +7,9 @@ use codex_core::WireApi; 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 core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::non_sandbox_test; use core_test_support::wait_for_event_with_timeout; use tempfile::TempDir; use wiremock::Mock; @@ -25,12 +25,7 @@ fn sse_completed(id: &str) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn continue_after_stream_error() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = MockServer::start().await; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 6e6938b932..4801376df1 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -9,10 +9,10 @@ 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 core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; +use core_test_support::non_sandbox_test; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; @@ -33,12 +33,7 @@ fn sse_completed(id: &str) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_on_early_close() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let server = MockServer::start().await; diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 489f34f9ce..9db1f1cf78 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -48,14 +48,9 @@ fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_apply_patch_tool() -> anyhow::Result<()> { use crate::suite::common::run_e2e_exec_test; - use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; + use core_test_support::non_sandbox_test; - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return Ok(()); - } + non_sandbox_test!(result); let tmp_cwd = tempdir().expect("failed to create temp dir"); let tmp_path = tmp_cwd.path().to_path_buf(); @@ -93,14 +88,9 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> { use crate::suite::common::run_e2e_exec_test; - use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; + use core_test_support::non_sandbox_test; - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return Ok(()); - } + non_sandbox_test!(result); let tmp_cwd = tempdir().expect("failed to create temp dir"); let freeform_add_patch = r#"*** Begin Patch diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 1e36014995..ea8095ff69 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -31,3 +31,4 @@ webbrowser = "1.0" [dev-dependencies] tempfile = "3" +core_test_support = { path = "../core/tests/common" } diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index dd2cb9048d..1acba00932 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -8,10 +8,10 @@ use std::time::Duration; use base64::Engine; use codex_login::ServerOptions; use codex_login::run_login_server; +use core_test_support::non_sandbox_test; use tempfile::tempdir; // See spawn.rs for details -pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_DISABLED"; fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { // Bind to a random available port @@ -77,12 +77,7 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { #[tokio::test] async fn end_to_end_login_flow_persists_auth_json() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let (issuer_addr, issuer_handle) = start_mock_issuer(); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); @@ -152,12 +147,7 @@ async fn end_to_end_login_flow_persists_auth_json() { #[tokio::test] async fn creates_missing_codex_home_dir() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let (issuer_addr, _issuer_handle) = start_mock_issuer(); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); @@ -196,12 +186,7 @@ async fn creates_missing_codex_home_dir() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancels_previous_login_server_when_port_is_in_use() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); let (issuer_addr, _issuer_handle) = start_mock_issuer(); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 9eb5f6eba6..0a0e938b31 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -46,3 +46,4 @@ os_info = "3.12.0" pretty_assertions = "1.4.1" tempfile = "3" wiremock = "0.6" +core_test_support = { path = "../core/tests/common" } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index e7097b6b34..78f758d69b 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -24,6 +24,7 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +use core_test_support::non_sandbox_test; use mcp_test_support::McpProcess; use mcp_test_support::create_apply_patch_sse_response; use mcp_test_support::create_final_assistant_message_sse_response; @@ -307,12 +308,7 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_codex_tool_passes_base_instructions() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); // Apparently `#[tokio::test]` must return `()`, so we create a helper // function that returns `Result` so we can use `?` in favor of `unwrap`. diff --git a/codex-rs/mcp-server/tests/suite/interrupt.rs b/codex-rs/mcp-server/tests/suite/interrupt.rs index 113a8dd291..e4daeae0de 100644 --- a/codex-rs/mcp-server/tests/suite/interrupt.rs +++ b/codex-rs/mcp-server/tests/suite/interrupt.rs @@ -4,7 +4,6 @@ use std::path::Path; use codex_core::protocol::TurnAbortReason; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::InterruptConversationResponse; @@ -12,6 +11,7 @@ use codex_protocol::mcp_protocol::NewConversationParams; use codex_protocol::mcp_protocol::NewConversationResponse; use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; +use core_test_support::non_sandbox_test; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use tempfile::TempDir; @@ -26,12 +26,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_shell_command_interruption() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } + non_sandbox_test!(); if let Err(err) = shell_command_interruption().await { panic!("failure: {err}"); From e5fe50d3ce4dc0e600e794502d8ce413ea1cbfe0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Sep 2025 18:47:01 +0200 Subject: [PATCH 27/42] chore: unify cargo versions (#4044) Unify cargo versions at root --- codex-rs/Cargo.lock | 42 +------ codex-rs/Cargo.toml | 123 ++++++++++++++++++++ codex-rs/ansi-escape/Cargo.toml | 6 +- codex-rs/apply-patch/Cargo.toml | 18 +-- codex-rs/arg0/Cargo.toml | 14 +-- codex-rs/chatgpt/Cargo.toml | 16 +-- codex-rs/cli/Cargo.toml | 42 +++---- codex-rs/common/Cargo.toml | 10 +- codex-rs/core/Cargo.toml | 106 ++++++++--------- codex-rs/core/tests/common/Cargo.toml | 10 +- codex-rs/exec/Cargo.toml | 44 +++---- codex-rs/execpolicy/Cargo.toml | 28 ++--- codex-rs/file-search/Cargo.toml | 14 +-- codex-rs/linux-sandbox/Cargo.toml | 14 +-- codex-rs/login/Cargo.toml | 34 +++--- codex-rs/login/src/pkce.rs | 2 +- codex-rs/login/src/server.rs | 2 +- codex-rs/mcp-client/Cargo.toml | 14 +-- codex-rs/mcp-server/Cargo.toml | 48 ++++---- codex-rs/mcp-server/tests/common/Cargo.toml | 24 ++-- codex-rs/mcp-types/Cargo.toml | 6 +- codex-rs/ollama/Cargo.toml | 18 +-- codex-rs/protocol-ts/Cargo.toml | 10 +- codex-rs/protocol/Cargo.toml | 32 ++--- codex-rs/tui/Cargo.toml | 104 ++++++++--------- 25 files changed, 437 insertions(+), 344 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 653e205b2e..c618fb51ad 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -687,7 +687,7 @@ dependencies = [ "portable-pty", "predicates", "pretty_assertions", - "rand 0.9.2", + "rand", "regex-lite", "reqwest", "seccompiler", @@ -798,7 +798,7 @@ dependencies = [ "codex-core", "codex-protocol", "core_test_support", - "rand 0.8.5", + "rand", "reqwest", "serde", "serde_json", @@ -936,7 +936,7 @@ dependencies = [ "pathdiff", "pretty_assertions", "pulldown-cmark", - "rand 0.9.2", + "rand", "ratatui", "regex-lite", "serde", @@ -3454,35 +3454,14 @@ dependencies = [ "nibble_vec", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -3492,16 +3471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", + "rand_core", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1502e62f2f..5850d09bd2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,124 @@ version = "0.0.0" # edition. edition = "2024" +[workspace.dependencies] +# Internal +codex-ansi-escape = { path = "ansi-escape" } +codex-apply-patch = { path = "apply-patch" } +codex-arg0 = { path = "arg0" } +codex-chatgpt = { path = "chatgpt" } +codex-common = { path = "common" } +codex-core = { path = "core" } +codex-exec = { path = "exec" } +codex-file-search = { path = "file-search" } +codex-linux-sandbox = { path = "linux-sandbox" } +codex-login = { path = "login" } +codex-mcp-client = { path = "mcp-client" } +codex-mcp-server = { path = "mcp-server" } +codex-ollama = { path = "ollama" } +codex-protocol = { path = "protocol" } +codex-protocol-ts = { path = "protocol-ts" } +codex-tui = { path = "tui" } +core_test_support = { path = "core/tests/common" } +mcp-types = { path = "mcp-types" } +mcp_test_support = { path = "mcp-server/tests/common" } + +# External +allocative = "0.3.3" +ansi-to-tui = "7.0.0" +anyhow = "1" +arboard = "3" +askama = "0.12" +assert_cmd = "2" +async-channel = "2.3.1" +async-stream = "0.3.6" +base64 = "0.22.1" +bytes = "1.10.1" +chrono = "0.4.40" +clap = "4" +clap_complete = "4" +color-eyre = "0.6.3" +crossterm = "0.28.1" +derive_more = "2" +diffy = "0.4.2" +dirs = "6" +dotenvy = "0.15.7" +env-flags = "0.1.1" +env_logger = "0.11.5" +eventsource-stream = "0.2.3" +futures = "0.3" +icu_decimal = "2.0.0" +icu_locale_core = "2.0.0" +ignore = "0.4.23" +image = { version = "^0.25.8", default-features = false } +insta = "1.43.2" +itertools = "0.14.0" +landlock = "0.4.1" +lazy_static = "1" +libc = "0.2.175" +log = "0.4" +maplit = "1.0.2" +mime_guess = "2.0.5" +multimap = "0.10.0" +nucleo-matcher = "0.3.1" +once_cell = "1" +openssl-sys = "*" +os_info = "3.12.0" +owo-colors = "4.2.0" +path-absolutize = "3.1.1" +path-clean = "1.0.1" +pathdiff = "0.2" +portable-pty = "0.9.0" +predicates = "3" +pretty_assertions = "1.4.1" +pulldown-cmark = "0.10" +rand = "0.9" +ratatui = "0.29.0" +regex-lite = "0.1.7" +reqwest = "0.12" +schemars = "0.8.22" +seccompiler = "0.5.0" +serde = "1" +serde_json = "1" +serde_with = "3.14" +sha1 = "0.10.6" +sha2 = "0.10" +shlex = "1.3.0" +similar = "2.7.0" +starlark = "0.13.0" +strum = "0.27.2" +strum_macros = "0.27.2" +supports-color = "3.0.2" +sys-locale = "0.3.2" +tempfile = "3.13.0" +textwrap = "0.16.2" +thiserror = "2.0.16" +time = "0.3" +tiny_http = "0.12" +tokio = "1" +tokio-stream = "0.1.17" +tokio-test = "0.4" +tokio-util = "0.7.16" +toml = "0.9.5" +toml_edit = "0.23.4" +tracing = "0.1.41" +tracing-appender = "0.2.3" +tracing-subscriber = "0.3.20" +tree-sitter = "0.25.9" +tree-sitter-bash = "0.25.0" +ts-rs = "11" +unicode-segmentation = "1.12.0" +unicode-width = "0.1" +url = "2" +urlencoding = "2.1" +uuid = "1" +vt100 = "0.16.2" +walkdir = "2.5.0" +webbrowser = "1.0" +which = "6" +wildmatch = "2.5.0" +wiremock = "0.6" + [workspace.lints] rust = {} @@ -38,6 +156,11 @@ redundant_clone = "deny" uninlined_format_args = "deny" unwrap_used = "deny" +# cargo-shear cannot see the platform-specific openssl-sys usage, so we +# silence the false positive here instead of deleting a real dependency. +[workspace.metadata.cargo-shear] +ignored = ["openssl-sys"] + [profile.release] lto = "fat" # Because we bundle some of these executables with the TypeScript CLI, we diff --git a/codex-rs/ansi-escape/Cargo.toml b/codex-rs/ansi-escape/Cargo.toml index ada675380d..4107a72754 100644 --- a/codex-rs/ansi-escape/Cargo.toml +++ b/codex-rs/ansi-escape/Cargo.toml @@ -8,9 +8,9 @@ name = "codex_ansi_escape" path = "src/lib.rs" [dependencies] -ansi-to-tui = "7.0.0" -ratatui = { version = "0.29.0", features = [ +ansi-to-tui = { workspace = true } +ratatui = { workspace = true, features = [ "unstable-rendered-line-info", "unstable-widget-ref", ] } -tracing = { version = "0.1.41", features = ["log"] } +tracing = { workspace = true, features = ["log"] } diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 7b5919a323..d37404c15b 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -15,14 +15,14 @@ path = "src/main.rs" workspace = true [dependencies] -anyhow = "1" -similar = "2.7.0" -thiserror = "2.0.16" -tree-sitter = "0.25.9" -tree-sitter-bash = "0.25.0" -once_cell = "1" +anyhow = { workspace = true } +similar = { workspace = true } +thiserror = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-bash = { workspace = true } +once_cell = { workspace = true } [dev-dependencies] -assert_cmd = "2" -pretty_assertions = "1.4.1" -tempfile = "3.13.0" +assert_cmd = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index a01120b798..10d09e4a4b 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -11,10 +11,10 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -codex-apply-patch = { path = "../apply-patch" } -codex-core = { path = "../core" } -codex-linux-sandbox = { path = "../linux-sandbox" } -dotenvy = "0.15.7" -tempfile = "3" -tokio = { version = "1", features = ["rt-multi-thread"] } +anyhow = { workspace = true } +codex-apply-patch = { workspace = true } +codex-core = { workspace = true } +codex-linux-sandbox = { workspace = true } +dotenvy = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index af5f910efe..97e14d7fe7 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -7,13 +7,13 @@ version = { workspace = true } workspace = true [dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -codex-common = { path = "../common", features = ["cli"] } -codex-core = { path = "../core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["full"] } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-common = { workspace = true, features = ["cli"] } +codex-core = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["full"] } [dev-dependencies] -tempfile = "3" +tempfile = { workspace = true } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 291ef47748..0d151a9000 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -15,32 +15,32 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -clap_complete = "4" -codex-arg0 = { path = "../arg0" } -codex-chatgpt = { path = "../chatgpt" } -codex-common = { path = "../common", features = ["cli"] } -codex-core = { path = "../core" } -codex-exec = { path = "../exec" } -codex-login = { path = "../login" } -codex-mcp-server = { path = "../mcp-server" } -codex-protocol = { path = "../protocol" } -codex-tui = { path = "../tui" } -serde_json = "1" -tokio = { version = "1", features = [ +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +clap_complete = { workspace = true } +codex-arg0 = { workspace = true } +codex-chatgpt = { workspace = true } +codex-common = { workspace = true, features = ["cli"] } +codex-core = { workspace = true } +codex-exec = { workspace = true } +codex-login = { workspace = true } +codex-mcp-server = { workspace = true } +codex-protocol = { workspace = true } +codex-tui = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -tracing = "0.1.41" -tracing-subscriber = "0.3.20" -codex-protocol-ts = { path = "../protocol-ts" } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +codex-protocol-ts = { workspace = true } [dev-dependencies] -assert_cmd = "2" -predicates = "3" -pretty_assertions = "1" -tempfile = "3" +assert_cmd = { workspace = true } +predicates = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index b10600574c..3ce84a6f50 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -7,11 +7,11 @@ version = { workspace = true } workspace = true [dependencies] -clap = { version = "4", features = ["derive", "wrap_help"], optional = true } -codex-core = { path = "../core" } -codex-protocol = { path = "../protocol" } -serde = { version = "1", optional = true } -toml = { version = "0.9", optional = true } +clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } +codex-core = { workspace = true } +codex-protocol = { workspace = true } +serde = { workspace = true, optional = true } +toml = { workspace = true, optional = true } [features] # Separate feature so that `clap` is not a mandatory dependency. diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9973a00442..d9ded08283 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -12,81 +12,81 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -askama = "0.12" -async-channel = "2.3.1" -base64 = "0.22" -bytes = "1.10.1" -chrono = { version = "0.4", features = ["serde"] } -codex-apply-patch = { path = "../apply-patch" } -codex-file-search = { path = "../file-search" } -codex-mcp-client = { path = "../mcp-client" } -codex-protocol = { path = "../protocol" } -dirs = "6" -env-flags = "0.1.1" -eventsource-stream = "0.2.3" -futures = "0.3" -libc = "0.2.175" -mcp-types = { path = "../mcp-types" } -os_info = "3.12.0" -portable-pty = "0.9.0" -rand = "0.9" -regex-lite = "0.1.7" -reqwest = { version = "0.12", features = ["json", "stream"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha1 = "0.10.6" -shlex = "1.3.0" -similar = "2.7.0" -strum_macros = "0.27.2" -tempfile = "3" -thiserror = "2.0.16" -time = { version = "0.3", features = [ +anyhow = { workspace = true } +askama = { workspace = true } +async-channel = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-apply-patch = { workspace = true } +codex-file-search = { workspace = true } +codex-mcp-client = { workspace = true } +codex-protocol = { workspace = true } +dirs = { workspace = true } +env-flags = { workspace = true } +eventsource-stream = { workspace = true } +futures = { workspace = true } +libc = { workspace = true } +mcp-types = { workspace = true } +os_info = { workspace = true } +portable-pty = { workspace = true } +rand = { workspace = true } +regex-lite = { workspace = true } +reqwest = { workspace = true, features = ["json", "stream"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +shlex = { workspace = true } +similar = { workspace = true } +strum_macros = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true, features = [ "formatting", "parsing", "local-offset", "macros", ] } -tokio = { version = "1", features = [ +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -tokio-util = "0.7.16" -toml = "0.9.5" -toml_edit = "0.23.4" -tracing = { version = "0.1.41", features = ["log"] } -tree-sitter = "0.25.9" -tree-sitter-bash = "0.25.0" -uuid = { version = "1", features = ["serde", "v4"] } -which = "6" -wildmatch = "2.5.0" +tokio-util = { workspace = true } +toml = { workspace = true } +toml_edit = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tree-sitter = { workspace = true } +tree-sitter-bash = { workspace = true } +uuid = { workspace = true, features = ["serde", "v4"] } +which = { workspace = true } +wildmatch = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] -landlock = "0.4.1" -seccompiler = "0.5.0" +landlock = { workspace = true } +seccompiler = { workspace = true } # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] -openssl-sys = { version = "*", features = ["vendored"] } +openssl-sys = { workspace = true, features = ["vendored"] } # Build OpenSSL from source for musl builds. [target.aarch64-unknown-linux-musl.dependencies] -openssl-sys = { version = "*", features = ["vendored"] } +openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] -assert_cmd = "2" -core_test_support = { path = "tests/common" } -maplit = "1.0.2" -predicates = "3" -pretty_assertions = "1.4.1" -tempfile = "3" -tokio-test = "0.4" -walkdir = "2.5.0" -wiremock = "0.6" +assert_cmd = { workspace = true } +core_test_support = { workspace = true } +maplit = { workspace = true } +predicates = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio-test = { workspace = true } +walkdir = { workspace = true } +wiremock = { workspace = true } [package.metadata.cargo-shear] ignored = ["openssl-sys"] diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 2d43051919..0a33be39dc 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -7,8 +7,8 @@ edition = "2024" path = "lib.rs" [dependencies] -codex-core = { path = "../.." } -serde_json = "1" -tempfile = "3" -tokio = { version = "1", features = ["time"] } -wiremock = "0.6" +codex-core = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["time"] } +wiremock = { workspace = true } diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 2dd121c368..44281c738d 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -15,37 +15,37 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -chrono = "0.4.40" -clap = { version = "4", features = ["derive"] } -codex-arg0 = { path = "../arg0" } -codex-common = { path = "../common", features = [ +anyhow = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-arg0 = { workspace = true } +codex-common = { workspace = true, features = [ "cli", "elapsed", "sandbox_summary", ] } -codex-core = { path = "../core" } -codex-ollama = { path = "../ollama" } -codex-protocol = { path = "../protocol" } -owo-colors = "4.2.0" -serde_json = "1" -shlex = "1.3.0" -tokio = { version = "1", features = [ +codex-core = { workspace = true } +codex-ollama = { workspace = true } +codex-protocol = { workspace = true } +owo-colors = { workspace = true } +serde_json = { workspace = true } +shlex = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +tracing = { workspace = true, features = ["log"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } [dev-dependencies] -assert_cmd = "2" -core_test_support = { path = "../core/tests/common" } -libc = "0.2" -predicates = "3" -tempfile = "3.13.0" -uuid = "1" -walkdir = "2" -wiremock = "0.6" +assert_cmd = { workspace = true } +core_test_support = { workspace = true } +libc = { workspace = true } +predicates = { workspace = true } +tempfile = { workspace = true } +uuid = { workspace = true } +walkdir = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index 9f0a25c422..0fe7cd486a 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -15,19 +15,19 @@ path = "src/lib.rs" workspace = true [dependencies] -allocative = "0.3.3" -anyhow = "1" -clap = { version = "4", features = ["derive"] } -derive_more = { version = "2", features = ["display"] } -env_logger = "0.11.5" -log = "0.4" -multimap = "0.10.0" -path-absolutize = "3.1.1" -regex-lite = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_with = { version = "3", features = ["macros"] } -starlark = "0.13.0" +allocative = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +derive_more = { workspace = true, features = ["display"] } +env_logger = { workspace = true } +log = { workspace = true } +multimap = { workspace = true } +path-absolutize = { workspace = true } +regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_with = { workspace = true, features = ["macros"] } +starlark = { workspace = true } [dev-dependencies] -tempfile = "3.13.0" +tempfile = { workspace = true } diff --git a/codex-rs/file-search/Cargo.toml b/codex-rs/file-search/Cargo.toml index 023e6936c8..40671389e1 100644 --- a/codex-rs/file-search/Cargo.toml +++ b/codex-rs/file-search/Cargo.toml @@ -12,10 +12,10 @@ name = "codex_file_search" path = "src/lib.rs" [dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -ignore = "0.4.23" -nucleo-matcher = "0.3.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["full"] } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +ignore = { workspace = true } +nucleo-matcher = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index e7432357b7..264f15e751 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -15,15 +15,15 @@ path = "src/lib.rs" workspace = true [target.'cfg(target_os = "linux")'.dependencies] -clap = { version = "4", features = ["derive"] } -codex-core = { path = "../core" } -landlock = "0.4.1" -libc = "0.2.175" -seccompiler = "0.5.0" +clap = { workspace = true, features = ["derive"] } +codex-core = { workspace = true } +landlock = { workspace = true } +libc = { workspace = true } +seccompiler = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] -tempfile = "3" -tokio = { version = "1", features = [ +tempfile = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index ea8095ff69..c0f4a2dd6f 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -7,28 +7,28 @@ version = { workspace = true } 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"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -tempfile = "3" -tiny_http = "0.12" -tokio = { version = "1", features = [ +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-core = { workspace = true } +codex-protocol = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["json", "blocking"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } +tiny_http = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -url = "2" -urlencoding = "2.1" -webbrowser = "1.0" +url = { workspace = true } +urlencoding = { workspace = true } +webbrowser = { workspace = true } [dev-dependencies] -tempfile = "3" -core_test_support = { path = "../core/tests/common" } +core_test_support = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/login/src/pkce.rs b/codex-rs/login/src/pkce.rs index 3c413b11f1..a0eacfc201 100644 --- a/codex-rs/login/src/pkce.rs +++ b/codex-rs/login/src/pkce.rs @@ -11,7 +11,7 @@ pub struct PkceCodes { pub fn generate_pkce() -> PkceCodes { let mut bytes = [0u8; 64]; - rand::thread_rng().fill_bytes(&mut bytes); + rand::rng().fill_bytes(&mut bytes); // Verifier: URL-safe base64 without padding (43..128 chars) let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 26f341b9d9..7255e4682b 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -326,7 +326,7 @@ fn build_authorize_url( fn generate_state() -> String { let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); + rand::rng().fill_bytes(&mut bytes); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } diff --git a/codex-rs/mcp-client/Cargo.toml b/codex-rs/mcp-client/Cargo.toml index 7c71d52180..5025063b0a 100644 --- a/codex-rs/mcp-client/Cargo.toml +++ b/codex-rs/mcp-client/Cargo.toml @@ -7,13 +7,13 @@ edition = "2024" workspace = true [dependencies] -anyhow = "1" -mcp-types = { path = "../mcp-types" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -tokio = { version = "1", features = [ +anyhow = { workspace = true } +mcp-types = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] } +tokio = { workspace = true, features = [ "io-util", "macros", "process", diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 0a0e938b31..e40dd15df7 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -15,35 +15,35 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -codex-arg0 = { path = "../arg0" } -codex-common = { path = "../common", features = ["cli"] } -codex-core = { path = "../core" } -codex-login = { path = "../login" } -codex-protocol = { path = "../protocol" } -mcp-types = { path = "../mcp-types" } -schemars = "0.8.22" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -shlex = "1.3.0" -tokio = { version = "1", features = [ +anyhow = { workspace = true } +codex-arg0 = { workspace = true } +codex-common = { workspace = true, features = ["cli"] } +codex-core = { workspace = true } +codex-login = { workspace = true } +codex-protocol = { workspace = true } +mcp-types = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +shlex = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -toml = "0.9" -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -uuid = { version = "1", features = ["serde", "v7"] } +toml = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] -assert_cmd = "2" -base64 = "0.22" -mcp_test_support = { path = "tests/common" } -os_info = "3.12.0" -pretty_assertions = "1.4.1" -tempfile = "3" -wiremock = "0.6" -core_test_support = { path = "../core/tests/common" } +assert_cmd = { workspace = true } +base64 = { workspace = true } +core_test_support = { workspace = true } +mcp_test_support = { workspace = true } +os_info = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/mcp-server/tests/common/Cargo.toml b/codex-rs/mcp-server/tests/common/Cargo.toml index 6bdef423cc..e6f7117250 100644 --- a/codex-rs/mcp-server/tests/common/Cargo.toml +++ b/codex-rs/mcp-server/tests/common/Cargo.toml @@ -7,20 +7,20 @@ version = { workspace = true } path = "lib.rs" [dependencies] -anyhow = "1" -assert_cmd = "2" -codex-core = { path = "../../../core" } -codex-mcp-server = { path = "../.." } -codex-protocol = { path = "../../../protocol" } -mcp-types = { path = "../../../mcp-types" } -os_info = "3.12.0" -pretty_assertions = "1.4.1" -serde = { version = "1" } -serde_json = "1" -tokio = { version = "1", features = [ +anyhow = { workspace = true } +assert_cmd = { workspace = true } +codex-core = { workspace = true } +codex-mcp-server = { workspace = true } +codex-protocol = { workspace = true } +mcp-types = { workspace = true } +os_info = { workspace = true } +pretty_assertions = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", ] } -wiremock = "0.6" +wiremock = { workspace = true } diff --git a/codex-rs/mcp-types/Cargo.toml b/codex-rs/mcp-types/Cargo.toml index e39cb64d37..c8dc5819cd 100644 --- a/codex-rs/mcp-types/Cargo.toml +++ b/codex-rs/mcp-types/Cargo.toml @@ -7,6 +7,6 @@ version = { workspace = true } workspace = true [dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -ts-rs = { version = "11", features = ["serde-json-impl", "no-serde-warnings"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ts-rs = { workspace = true, features = ["serde-json-impl", "no-serde-warnings"] } diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index e725c2a290..587a193069 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -11,20 +11,20 @@ path = "src/lib.rs" workspace = true [dependencies] -async-stream = "0.3" -bytes = "1.10.1" -codex-core = { path = "../core" } -futures = "0.3" -reqwest = { version = "0.12", features = ["json", "stream"] } -serde_json = "1" -tokio = { version = "1", features = [ +async-stream = { workspace = true } +bytes = { workspace = true } +codex-core = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true, features = ["json", "stream"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -tracing = { version = "0.1.41", features = ["log"] } -wiremock = "0.6" +tracing = { workspace = true, features = ["log"] } +wiremock = { workspace = true } [dev-dependencies] diff --git a/codex-rs/protocol-ts/Cargo.toml b/codex-rs/protocol-ts/Cargo.toml index 1131a62196..3a0233dec3 100644 --- a/codex-rs/protocol-ts/Cargo.toml +++ b/codex-rs/protocol-ts/Cargo.toml @@ -15,8 +15,8 @@ name = "codex-protocol-ts" path = "src/main.rs" [dependencies] -anyhow = "1" -mcp-types = { path = "../mcp-types" } -codex-protocol = { path = "../protocol" } -ts-rs = "11" -clap = { version = "4", features = ["derive"] } +anyhow = { workspace = true } +mcp-types = { workspace = true } +codex-protocol = { workspace = true } +ts-rs = { workspace = true } +clap = { workspace = true, features = ["derive"] } diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index bbe2ed3f65..9b530712cd 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -11,28 +11,28 @@ path = "src/lib.rs" workspace = true [dependencies] -base64 = "0.22.1" -icu_decimal = "2.0.0" -icu_locale_core = "2.0.0" -mcp-types = { path = "../mcp-types" } -mime_guess = "2.0.5" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_with = { version = "3.14.0", features = ["macros", "base64"] } -strum = "0.27.2" -strum_macros = "0.27.2" -sys-locale = "0.3.2" -tracing = "0.1.41" -ts-rs = { version = "11", features = [ +base64 = { workspace = true } +icu_decimal = { workspace = true } +icu_locale_core = { workspace = true } +mcp-types = { workspace = true } +mime_guess = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_with = { workspace = true, features = ["macros", "base64"] } +strum = { workspace = true } +strum_macros = { workspace = true } +sys-locale = { workspace = true } +tracing = { workspace = true } +ts-rs = { workspace = true, features = [ "uuid-impl", "serde-json-impl", "no-serde-warnings", ] } -uuid = { version = "1", features = ["serde", "v7"] } +uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] -pretty_assertions = "1.4.1" -tempfile = "3" +pretty_assertions = { workspace = true } +tempfile = { workspace = true } [package.metadata.cargo-shear] # Required because the not imported as strum_macros in non-nightly builds. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c7a5315844..b029f72216 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -21,84 +21,84 @@ debug-logs = [] workspace = true [dependencies] -anyhow = "1" -async-stream = "0.3.6" -base64 = "0.22.1" -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive"] } -codex-ansi-escape = { path = "../ansi-escape" } -codex-arg0 = { path = "../arg0" } -codex-common = { path = "../common", features = [ +anyhow = { workspace = true } +async-stream = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive"] } +codex-ansi-escape = { workspace = true } +codex-arg0 = { workspace = true } +codex-common = { workspace = true, features = [ "cli", "elapsed", "sandbox_summary", ] } -codex-core = { path = "../core" } -codex-file-search = { path = "../file-search" } -codex-login = { path = "../login" } -codex-ollama = { path = "../ollama" } -codex-protocol = { path = "../protocol" } -color-eyre = "0.6.3" -crossterm = { version = "0.28.1", features = [ +codex-core = { workspace = true } +codex-file-search = { workspace = true } +codex-login = { workspace = true } +codex-ollama = { workspace = true } +codex-protocol = { workspace = true } +color-eyre = { workspace = true } +crossterm = { workspace = true, features = [ "bracketed-paste", "event-stream", ] } -dirs = "6" -diffy = "0.4.2" -image = { version = "^0.25.8", default-features = false, features = [ +dirs = { workspace = true } +diffy = { workspace = true } +image = { workspace = true, features = [ "jpeg", "png", ] } -itertools = "0.14.0" -lazy_static = "1" -mcp-types = { path = "../mcp-types" } -once_cell = "1" -path-clean = "1.0.1" -rand = "0.9" -ratatui = { version = "0.29.0", features = [ +itertools = { workspace = true } +lazy_static = { workspace = true } +mcp-types = { workspace = true } +once_cell = { workspace = true } +path-clean = { workspace = true } +rand = { workspace = true } +ratatui = { workspace = true, features = [ "scrolling-regions", "unstable-rendered-line-info", "unstable-widget-ref", ] } -regex-lite = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } -shlex = "1.3.0" -strum = "0.27.2" -strum_macros = "0.27.2" -supports-color = "3.0.2" -tempfile = "3" -textwrap = "0.16.2" -tokio = { version = "1", features = [ +regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +shlex = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } +supports-color = { workspace = true } +tempfile = { workspace = true } +textwrap = { workspace = true } +tokio = { workspace = true, features = [ "io-std", "macros", "process", "rt-multi-thread", "signal", ] } -tokio-stream = "0.1.17" -tracing = { version = "0.1.41", features = ["log"] } -tracing-appender = "0.2.3" -tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } -pulldown-cmark = "0.10" -unicode-segmentation = "1.12.0" -unicode-width = "0.1" -url = "2" -pathdiff = "0.2" -owo-colors = "4.2.0" +tokio-stream = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +pulldown-cmark = { workspace = true } +unicode-segmentation = { workspace = true } +unicode-width = { workspace = true } +url = { workspace = true } +pathdiff = { workspace = true } +owo-colors = { workspace = true } [target.'cfg(unix)'.dependencies] -libc = "0.2" +libc = { workspace = true } # 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" +arboard = { workspace = true } [dev-dependencies] -chrono = { version = "0.4", features = ["serde"] } -insta = "1.43.2" -pretty_assertions = "1" -rand = "0.9" -vt100 = "0.16.2" +chrono = { workspace = true, features = ["serde"] } +insta = { workspace = true } +pretty_assertions = { workspace = true } +rand = { workspace = true } +vt100 = { workspace = true } From e258ca61b43c0820048fd8b10e814b59a4956d44 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Sep 2025 19:16:02 +0200 Subject: [PATCH 28/42] chore: more clippy rules 2 (#4057) The only file to watch is the cargo.toml All the others come from just fix + a few manual small fix The set of rules have been taken from the list of clippy rules arbitrarily while trying to optimise the learning and style of the code while limiting the loss of productivity --- codex-rs/Cargo.toml | 27 ++++++++++++++++++ codex-rs/core/src/turn_diff_tracker.rs | 10 +++---- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +-- .../tui/src/bottom_pane/custom_prompt_view.rs | 4 +-- codex-rs/tui/src/bottom_pane/textarea.rs | 28 +++++++++---------- codex-rs/tui/src/history_cell.rs | 5 ++-- codex-rs/tui/src/markdown_stream.rs | 6 ++-- codex-rs/tui/src/wrapping.rs | 4 +-- 8 files changed, 56 insertions(+), 32 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5850d09bd2..1ec4191e7a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -152,8 +152,35 @@ rust = {} [workspace.lints.clippy] expect_used = "deny" +identity_op = "deny" +manual_clamp = "deny" +manual_filter = "deny" +manual_find = "deny" +manual_flatten = "deny" +manual_map = "deny" +manual_memcpy = "deny" +manual_non_exhaustive = "deny" +manual_ok_or = "deny" +manual_range_contains = "deny" +manual_retain = "deny" +manual_strip = "deny" +manual_try_fold = "deny" +manual_unwrap_or = "deny" +needless_borrow = "deny" +needless_borrowed_reference = "deny" +needless_collect = "deny" +needless_late_init = "deny" +needless_option_as_deref = "deny" +needless_question_mark = "deny" +needless_update = "deny" redundant_clone = "deny" +redundant_static_lifetimes = "deny" +trivially_copy_pass_by_ref = "deny" uninlined_format_args = "deny" +unnecessary_filter_map = "deny" +unnecessary_lazy_evaluations = "deny" +unnecessary_sort_by = "deny" +unnecessary_to_owned = "deny" unwrap_used = "deny" # cargo-shear cannot see the platform-specific openssl-sys usage, so we diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 6c12d6cd46..06c40deb90 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -65,7 +65,7 @@ impl TurnDiffTracker { let baseline_file_info = if path.exists() { let mode = file_mode_for_path(path); let mode_val = mode.unwrap_or(FileMode::Regular); - let content = blob_bytes(path, &mode_val).unwrap_or_default(); + let content = blob_bytes(path, mode_val).unwrap_or_default(); let oid = if mode == Some(FileMode::Symlink) { format!("{:x}", git_blob_sha1_hex_bytes(&content)) } else { @@ -266,7 +266,7 @@ impl TurnDiffTracker { }; let current_mode = file_mode_for_path(¤t_external_path).unwrap_or(FileMode::Regular); - let right_bytes = blob_bytes(¤t_external_path, ¤t_mode); + let right_bytes = blob_bytes(¤t_external_path, current_mode); // Compute displays with &mut self before borrowing any baseline content. let left_display = self.relative_to_git_root_str(&baseline_external_path); @@ -388,7 +388,7 @@ enum FileMode { } impl FileMode { - fn as_str(&self) -> &'static str { + fn as_str(self) -> &'static str { match self { FileMode::Regular => "100644", #[cfg(unix)] @@ -427,9 +427,9 @@ fn file_mode_for_path(_path: &Path) -> Option { Some(FileMode::Regular) } -fn blob_bytes(path: &Path, mode: &FileMode) -> Option> { +fn blob_bytes(path: &Path, mode: FileMode) -> Option> { if path.exists() { - let contents = if *mode == FileMode::Symlink { + let contents = if mode == FileMode::Symlink { symlink_blob_bytes(path) .ok_or_else(|| anyhow!("failed to read symlink target for {}", path.display())) } else { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bf11ff571d..d15265c50a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -161,8 +161,8 @@ impl ChatComposer { // Leave 1 for border and 1 for padding textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS); textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); - let state = self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, &state) + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) } /// Returns true if the composer currently contains no user input. diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 4bad707724..b498d87811 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -236,8 +236,8 @@ impl BottomPaneView for CustomPromptView { width: area.width.saturating_sub(2), height: text_area_height, }; - let state = self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, &state) + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) } } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 9166b8c7f6..269fa345e0 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -132,11 +132,11 @@ impl TextArea { #[cfg_attr(not(test), allow(dead_code))] pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.cursor_pos_with_state(area, &TextAreaState::default()) + self.cursor_pos_with_state(area, TextAreaState::default()) } /// Compute the on-screen cursor position taking scrolling into account. - pub fn cursor_pos_with_state(&self, area: Rect, state: &TextAreaState) -> Option<(u16, u16)> { + pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { let lines = self.wrapped_lines(area.width); let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; @@ -1408,7 +1408,7 @@ mod tests { let mut state = TextAreaState::default(); let small_area = Rect::new(0, 0, 6, 1); // First call: cursor not visible -> effective scroll ensures it is - let (_x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); assert_eq!(y, 0); // Render with state to update actual scroll value @@ -1429,7 +1429,7 @@ mod tests { // effective scroll is 0 and the cursor position matches cursor_pos. let bad_state = TextAreaState { scroll: 999 }; let (x1, y1) = t.cursor_pos(area).unwrap(); - let (x2, y2) = t.cursor_pos_with_state(area, &bad_state).unwrap(); + let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap(); assert_eq!((x2, y2), (x1, y1)); // Case 2: Cursor below the current window — y should be clamped to the @@ -1442,7 +1442,7 @@ mod tests { t.set_cursor(t.text().len().saturating_sub(2)); let small_area = Rect::new(0, 0, wrap_width, 2); let state = TextAreaState { scroll: 0 }; - let (_x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); assert_eq!(y, small_area.y + small_area.height - 1); // Case 3: Cursor above the current window — y should be top row (0) @@ -1456,7 +1456,7 @@ mod tests { let state = TextAreaState { scroll: lines.saturating_mul(2), }; - let (_x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!(y, area.y); } @@ -1480,7 +1480,7 @@ mod tests { // With state and small height, cursor should be visible at row 0, col 0 let small_area = Rect::new(0, 0, 4, 1); let state = TextAreaState::default(); - let (x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); assert_eq!((x, y), (0, 0)); // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' @@ -1510,35 +1510,35 @@ mod tests { // Start at beginning t.set_cursor(0); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x, y), (0, 0)); // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport t.move_cursor_down(); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x, y), (0, 1)); // Move down to third visual line; viewport scrolls and keeps cursor on bottom row t.move_cursor_down(); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x, y), (0, 1)); // Move up to second visual line; with current scroll, it appears on top row t.move_cursor_up(); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x, y), (0, 0)); // Column preservation across moves: set to col 2 on first line, move down t.set_cursor(2); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x0, y0) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x0, y0), (2, 0)); t.move_cursor_down(); ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x1, y1) = t.cursor_pos_with_state(area, &state).unwrap(); + let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!((x1, y1), (2, 1)); } @@ -1796,7 +1796,7 @@ mod tests { // cursor_pos_with_state: always within viewport rows let (_x, _y) = ta - .cursor_pos_with_state(area, &state) + .cursor_pos_with_state(area, state) .unwrap_or((area.x, area.y)); // Stateful render should not panic, and updates scroll diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 24e6b2844f..c060715ee1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -390,15 +390,14 @@ impl ExecCell { .iter() .all(|c| matches!(c, ParsedCommand::Read { .. })) { - let names: Vec = call + let names = call .parsed .iter() .map(|c| match c { ParsedCommand::Read { name, .. } => name.clone(), _ => unreachable!(), }) - .unique() - .collect(); + .unique(); vec![( "Read", itertools::Itertools::intersperse( diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 12b4308210..a9428b891b 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -310,10 +310,8 @@ mod tests { let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation."; let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg); // Wrap to a narrow width to force multiple output lines. - let wrapped = crate::wrapping::word_wrap_lines( - out.iter().collect::>(), - crate::wrapping::RtOptions::new(24), - ); + let wrapped = + crate::wrapping::word_wrap_lines(out.iter(), crate::wrapping::RtOptions::new(24)); // Filter out purely blank lines let non_blank: Vec<_> = wrapped .into_iter() diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index c97f0e3c6c..1dc4e2e553 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -511,7 +511,7 @@ mod tests { .subsequent_indent(Line::from(" ")); let lines = [Line::from("hello world"), Line::from("foo bar baz")]; - let out = word_wrap_lines_borrowed(lines.iter().collect::>(), opts); + let out = word_wrap_lines_borrowed(lines.iter(), opts); let rendered: Vec = out.iter().map(concat_line).collect(); assert!(rendered.first().unwrap().starts_with("- ")); @@ -523,7 +523,7 @@ mod tests { #[test] fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() { let lines = [Line::from("hello"), Line::from("world!")]; - let out = word_wrap_lines_borrowed(lines.iter().collect::>(), 10); + let out = word_wrap_lines_borrowed(lines.iter(), 10); let rendered: Vec = out.iter().map(concat_line).collect(); assert_eq!(rendered, vec!["hello", "world!"]); } From 19f46439aeeda571790b6b58dfba8c621c9d56ba Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:30:59 -0700 Subject: [PATCH 29/42] timeouts for mcp tool calls (#3959) defaults to 60sec, overridable with MCP_TOOL_TIMEOUT or on a per-server basis in the config. --- codex-rs/cli/src/mcp_cmd.rs | 24 ++++-- codex-rs/core/src/codex.rs | 10 +-- codex-rs/core/src/config.rs | 42 +++++++--- codex-rs/core/src/config_types.rs | 87 ++++++++++++++++++++- codex-rs/core/src/mcp_connection_manager.rs | 39 ++++----- codex-rs/core/src/mcp_tool_call.rs | 4 +- docs/config.md | 10 ++- 7 files changed, 166 insertions(+), 50 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 437511ad57..465de71aac 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -148,7 +148,8 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<( command: command_bin, args: command_args, env: env_map, - startup_timeout_ms: None, + startup_timeout_sec: None, + tool_timeout_sec: None, }; servers.insert(name.clone(), new_entry); @@ -210,7 +211,12 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul "command": cfg.command, "args": cfg.args, "env": env, - "startup_timeout_ms": cfg.startup_timeout_ms, + "startup_timeout_sec": cfg + .startup_timeout_sec + .map(|timeout| timeout.as_secs_f64()), + "tool_timeout_sec": cfg + .tool_timeout_sec + .map(|timeout| timeout.as_secs_f64()), }) }) .collect(); @@ -305,7 +311,12 @@ fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<( "command": server.command, "args": server.args, "env": env, - "startup_timeout_ms": server.startup_timeout_ms, + "startup_timeout_sec": server + .startup_timeout_sec + .map(|timeout| timeout.as_secs_f64()), + "tool_timeout_sec": server + .tool_timeout_sec + .map(|timeout| timeout.as_secs_f64()), }))?; println!("{output}"); return Ok(()); @@ -333,8 +344,11 @@ fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<( } }; println!(" env: {env_display}"); - if let Some(timeout) = server.startup_timeout_ms { - println!(" startup_timeout_ms: {timeout}"); + if let Some(timeout) = server.startup_timeout_sec { + println!(" startup_timeout_sec: {}", timeout.as_secs_f64()); + } + if let Some(timeout) = server.tool_timeout_sec { + println!(" tool_timeout_sec: {}", timeout.as_secs_f64()); } println!(" remove: codex mcp remove {}", get_args.name); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 05ef09377e..dfea891921 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1008,10 +1008,9 @@ impl Session { server: &str, tool: &str, arguments: Option, - timeout: Option, ) -> anyhow::Result { self.mcp_connection_manager - .call_tool(server, tool, arguments, timeout) + .call_tool(server, tool, arguments) .await } @@ -2596,12 +2595,7 @@ async fn handle_function_call( _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { - // TODO(mbolin): Determine appropriate timeout for tool call. - let timeout = None; - handle_mcp_tool_call( - sess, &sub_id, call_id, server, tool_name, arguments, timeout, - ) - .await + handle_mcp_tool_call(sess, &sub_id, call_id, server, tool_name, arguments).await } None => { // Unknown function: reply with structured failure so the model can adapt. diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c84d84e0ff..6d1f2ee170 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -333,14 +333,12 @@ pub fn write_global_mcp_servers( entry["env"] = TomlItem::Table(env_table); } - if let Some(timeout) = config.startup_timeout_ms { - let timeout = i64::try_from(timeout).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "startup_timeout_ms exceeds supported range", - ) - })?; - entry["startup_timeout_ms"] = toml_edit::value(timeout); + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = toml_edit::value(timeout.as_secs_f64()); + } + + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = toml_edit::value(timeout.as_secs_f64()); } doc["mcp_servers"][name.as_str()] = TomlItem::Table(entry); @@ -1168,6 +1166,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + use std::time::Duration; use tempfile::TempDir; #[test] @@ -1292,7 +1291,8 @@ exclude_slash_tmp = true command: "echo".to_string(), args: vec!["hello".to_string()], env: None, - startup_timeout_ms: None, + startup_timeout_sec: Some(Duration::from_secs(3)), + tool_timeout_sec: Some(Duration::from_secs(5)), }, ); @@ -1303,6 +1303,8 @@ exclude_slash_tmp = true let docs = loaded.get("docs").expect("docs entry"); assert_eq!(docs.command, "echo"); assert_eq!(docs.args, vec!["hello".to_string()]); + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); + assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); let empty = BTreeMap::new(); write_global_mcp_servers(codex_home.path(), &empty)?; @@ -1312,6 +1314,28 @@ exclude_slash_tmp = true Ok(()) } + #[test] + fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + std::fs::write( + &config_path, + r#" +[mcp_servers] +[mcp_servers.docs] +command = "echo" +startup_timeout_ms = 2500 +"#, + )?; + + let servers = load_global_mcp_servers(codex_home.path())?; + let docs = servers.get("docs").expect("docs entry"); + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500))); + + Ok(()) + } + #[tokio::test] async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3737649871..d273b23d69 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -5,11 +5,15 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; use wildmatch::WildMatchPattern; use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { pub command: String, @@ -19,9 +23,84 @@ pub struct McpServerConfig { #[serde(default)] pub env: Option>, - /// Startup timeout in milliseconds for initializing MCP server & initially listing tools. - #[serde(default)] - pub startup_timeout_ms: Option, + /// Startup timeout in seconds for initializing MCP server & initially listing tools. + #[serde( + default, + with = "option_duration_secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_sec: Option, + + /// Default timeout for MCP tool calls initiated via this server. + #[serde(default, with = "option_duration_secs")] + pub tool_timeout_sec: Option, +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawMcpServerConfig { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: Option>, + #[serde(default)] + startup_timeout_sec: Option, + #[serde(default)] + startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + tool_timeout_sec: Option, + } + + let raw = RawMcpServerConfig::deserialize(deserializer)?; + + let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { + (Some(sec), _) => { + let duration = Duration::try_from_secs_f64(sec).map_err(SerdeError::custom)?; + Some(duration) + } + (None, Some(ms)) => Some(Duration::from_millis(ms)), + (None, None) => None, + }; + + Ok(Self { + command: raw.command, + args: raw.args, + env: raw.env, + startup_timeout_sec, + tool_timeout_sec: raw.tool_timeout_sec, + }) + } +} + +mod option_duration_secs { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } } #[derive(Deserialize, Debug, Copy, Clone, PartialEq)] diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index eecda78887..e9c95fc80b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -40,6 +40,9 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; /// Default timeout for initializing MCP server & initially listing tools. const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +/// Default timeout for individual tool calls. +const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); + /// Map that holds a startup error for every MCP server that could **not** be /// spawned successfully. pub type ClientStartErrors = HashMap; @@ -85,6 +88,7 @@ struct ToolInfo { struct ManagedClient { client: Arc, startup_timeout: Duration, + tool_timeout: Option, } /// A thin wrapper around a set of running [`McpClient`] instances. @@ -132,10 +136,9 @@ impl McpConnectionManager { continue; } - let startup_timeout = cfg - .startup_timeout_ms - .map(Duration::from_millis) - .unwrap_or(DEFAULT_STARTUP_TIMEOUT); + let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); + + let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT); join_set.spawn(async move { let McpServerConfig { @@ -171,19 +174,19 @@ impl McpConnectionManager { protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(), }; let initialize_notification_params = None; - match client + let init_result = client .initialize( params, initialize_notification_params, Some(startup_timeout), ) - .await - { - Ok(_response) => (server_name, Ok((client, startup_timeout))), - Err(e) => (server_name, Err(e)), - } + .await; + ( + (server_name, tool_timeout), + init_result.map(|_| (client, startup_timeout)), + ) } - Err(e) => (server_name, Err(e.into())), + Err(e) => ((server_name, tool_timeout), Err(e.into())), } }); } @@ -191,8 +194,8 @@ impl McpConnectionManager { let mut clients: HashMap = HashMap::with_capacity(join_set.len()); while let Some(res) = join_set.join_next().await { - let (server_name, client_res) = match res { - Ok((server_name, client_res)) => (server_name, client_res), + let ((server_name, tool_timeout), client_res) = match res { + Ok(result) => result, Err(e) => { warn!("Task panic when starting MCP server: {e:#}"); continue; @@ -206,6 +209,7 @@ impl McpConnectionManager { ManagedClient { client: Arc::new(client), startup_timeout, + tool_timeout: Some(tool_timeout), }, ); } @@ -243,14 +247,13 @@ impl McpConnectionManager { server: &str, tool: &str, arguments: Option, - timeout: Option, ) -> Result { - let client = self + let managed = self .clients .get(server) - .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))? - .client - .clone(); + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; client .call_tool(tool.to_string(), arguments, timeout) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 57b29262d2..7091a05656 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,4 +1,3 @@ -use std::time::Duration; use std::time::Instant; use tracing::error; @@ -21,7 +20,6 @@ pub(crate) async fn handle_mcp_tool_call( server: String, tool_name: String, arguments: String, - timeout: Option, ) -> ResponseInputItem { // Parse the `arguments` as JSON. An empty string is OK, but invalid JSON // is not. @@ -58,7 +56,7 @@ pub(crate) async fn handle_mcp_tool_call( let start = Instant::now(); // Perform the tool call. let result = sess - .call_tool(&server, &tool_name, arguments_value.clone(), timeout) + .call_tool(&server, &tool_name, arguments_value.clone()) .await .map_err(|e| format!("tool call error: {e}")); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { diff --git a/docs/config.md b/docs/config.md index 4f287b824e..5869203d57 100644 --- a/docs/config.md +++ b/docs/config.md @@ -342,7 +342,8 @@ Defines the list of MCP servers that Codex can consult for tool use. Currently, **Note:** Codex may cache the list of tools and resources from an MCP server so that Codex can include this information in context at startup without spawning all the servers. This is designed to save resources by loading MCP servers lazily. -Each server may set `startup_timeout_ms` to adjust how long Codex waits for it to start and respond to a tools listing. The default is `10_000` (10 seconds). +Each server may set `startup_timeout_sec` to adjust how long Codex waits for it to start and respond to a tools listing. The default is `10` seconds. +Similarly, `tool_timeout_sec` limits how long individual tool calls may run (default: `60` seconds), and Codex will fall back to the default when this value is omitted. This config option is comparable to how Claude and Cursor define `mcpServers` in their respective JSON config files, though because Codex uses TOML for its config language, the format is slightly different. For example, the following config in JSON: @@ -369,7 +370,9 @@ command = "npx" args = ["-y", "mcp-server"] env = { "API_KEY" = "value" } # Optional: override the default 10s startup timeout -startup_timeout_ms = 20_000 +startup_timeout_sec = 20 +# Optional: override the default 60s per-tool timeout +tool_timeout_sec = 30 ``` You can also manage these entries from the CLI [experimental]: @@ -619,7 +622,8 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `mcp_servers..command` | string | MCP server launcher command. | | `mcp_servers..args` | array | MCP server args. | | `mcp_servers..env` | map | MCP server env vars. | -| `mcp_servers..startup_timeout_ms` | number | Startup timeout in milliseconds (default: 10_000). Timeout is applied both for initializing MCP server and initially listing tools. | +| `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. | +| `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. | | `model_providers..name` | string | Display name. | | `model_providers..base_url` | string | API base URL. | | `model_providers..env_key` | string | Env var for API key. | From 434eb4fd494a92174b2f916295f3a79ff0ad5fd9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Sep 2025 11:13:34 -0700 Subject: [PATCH 30/42] Add limits to /status (#4053) Add limits to status image --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/history_cell.rs | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b4204f3a37..be27fbecd1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1368,6 +1368,7 @@ impl ChatWidget { &self.config, usage_ref, &self.conversation_id, + self.rate_limit_snapshot.as_ref(), )); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c060715ee1..89d3fd6903 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -58,6 +58,10 @@ use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; +const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; +const STATUS_LIMIT_BAR_FILLED: &str = "█"; +const STATUS_LIMIT_BAR_EMPTY: &str = " "; + #[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, @@ -1123,6 +1127,7 @@ pub(crate) fn new_status_output( config: &Config, usage: &TokenUsage, session_id: &Option, + rate_limits: Option<&RateLimitSnapshotEvent>, ) -> PlainHistoryCell { let mut lines: Vec> = Vec::new(); lines.push("/status".magenta().into()); @@ -1283,6 +1288,9 @@ pub(crate) fn new_status_output( format_with_separators(usage.blended_total()).into(), ])); + lines.push("".into()); + lines.extend(build_status_limit_lines(rate_limits)); + PlainHistoryCell { lines } } @@ -1640,6 +1648,62 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { invocation_spans.into() } +fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotEvent>) -> Vec> { + let mut lines: Vec> = + vec![vec![padded_emoji("⏱️").into(), "Usage Limits".bold()].into()]; + + match snapshot { + Some(snapshot) => { + let rows = [ + ("5h limit".to_string(), snapshot.primary_used_percent), + ("Weekly limit".to_string(), snapshot.weekly_used_percent), + ]; + let label_width = rows + .iter() + .map(|(label, _)| UnicodeWidthStr::width(label.as_str())) + .max() + .unwrap_or(0); + for (label, percent) in rows { + lines.push(build_status_limit_line(&label, percent, label_width)); + } + } + None => lines.push(" • Rate limit data not available yet.".dim().into()), + } + + lines +} + +fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -> Line<'static> { + let clamped_percent = percent_used.clamp(0.0, 100.0); + let progress = render_status_limit_progress_bar(clamped_percent); + let summary = format_status_limit_summary(clamped_percent); + + let mut spans: Vec> = Vec::with_capacity(5); + let padded_label = format!("{label: String { + let ratio = (percent_used / 100.0).clamp(0.0, 1.0); + let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; + let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); + let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); + format!( + "[{}{}]", + STATUS_LIMIT_BAR_FILLED.repeat(filled), + STATUS_LIMIT_BAR_EMPTY.repeat(empty) + ) +} + +fn format_status_limit_summary(percent_used: f64) -> String { + format!("{percent_used:.0}% used") +} + #[cfg(test)] mod tests { use super::*; From fa80bbb587d5914022157f6528d4a7b18c1a67ed Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:14:04 -0700 Subject: [PATCH 31/42] simplify StreamController (#3928) no intended functional change, just simplifying the code. --- codex-rs/tui/src/chatwidget.rs | 74 +++++++----- codex-rs/tui/src/chatwidget/tests.rs | 14 +-- codex-rs/tui/src/lib.rs | 18 ++- codex-rs/tui/src/markdown_stream.rs | 14 --- codex-rs/tui/src/streaming/controller.rs | 148 ++++------------------- codex-rs/tui/src/streaming/mod.rs | 38 ------ 6 files changed, 86 insertions(+), 220 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index be27fbecd1..0702eff564 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -171,7 +171,7 @@ pub(crate) struct ChatWidget { rate_limit_snapshot: Option, rate_limit_warnings: RateLimitWarningState, // Stream lifecycle controller - stream: StreamController, + stream_controller: Option, running_commands: HashMap, task_complete_pending: bool, // Queue of interruptive UI events deferred during an active write cycle @@ -219,8 +219,10 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio impl ChatWidget { fn flush_answer_stream_with_separator(&mut self) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - let _ = self.stream.finalize(true, &sink); + if let Some(mut controller) = self.stream_controller.take() { + let sink = AppEventHistorySink(self.app_event_tx.clone()); + controller.finalize(&sink); + } } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { @@ -249,9 +251,13 @@ impl ChatWidget { } fn on_agent_message(&mut self, message: String) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - let finished = self.stream.apply_final_answer(&message, &sink); - self.handle_if_stream_finished(finished); + // If we have a stream_controller, then the final agent message is redundant and will be a + // duplicate of what has already been streamed. + if self.stream_controller.is_none() { + self.handle_streaming_delta(message); + } + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); self.request_redraw(); } @@ -301,7 +307,6 @@ impl ChatWidget { fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); - self.stream.reset_headers_for_new_turn(); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); @@ -310,9 +315,9 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize only that stream to flush any tail // without emitting stray headers for other streams. - if self.stream.is_write_cycle_active() { + if let Some(mut controller) = self.stream_controller.take() { let sink = AppEventHistorySink(self.app_event_tx.clone()); - let _ = self.stream.finalize(true, &sink); + controller.finalize(&sink); } // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); @@ -353,7 +358,7 @@ impl ChatWidget { // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); - self.stream.clear_all(); + self.stream_controller = None; } fn on_error(&mut self, message: String) { @@ -508,12 +513,13 @@ impl ChatWidget { /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - let finished = self.stream.on_commit_tick(&sink); - self.handle_if_stream_finished(finished); - } - fn is_write_cycle_active(&self) -> bool { - self.stream.is_write_cycle_active() + if let Some(controller) = self.stream_controller.as_mut() { + let sink = AppEventHistorySink(self.app_event_tx.clone()); + let finished = controller.on_commit_tick(&sink); + if finished { + self.handle_stream_finished(); + } + } } fn flush_interrupt_queue(&mut self) { @@ -531,32 +537,34 @@ impl ChatWidget { // Preserve deterministic FIFO across queued interrupts: once anything // is queued due to an active write cycle, continue queueing until the // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). - if self.is_write_cycle_active() || !self.interrupts.is_empty() { + if self.stream_controller.is_some() || !self.interrupts.is_empty() { push(&mut self.interrupts); } else { handle(self); } } - #[inline] - fn handle_if_stream_finished(&mut self, finished: bool) { - if finished { - if self.task_complete_pending { - self.bottom_pane.set_task_running(false); - self.task_complete_pending = false; - } - // A completed stream indicates non-exec content was just inserted. - self.flush_interrupt_queue(); + fn handle_stream_finished(&mut self) { + if self.task_complete_pending { + self.bottom_pane.set_task_running(false); + self.task_complete_pending = false; } + // A completed stream indicates non-exec content was just inserted. + 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); + + if self.stream_controller.is_none() { + self.stream_controller = Some(StreamController::new(self.config.clone())); + } + if let Some(controller) = self.stream_controller.as_mut() { + let sink = AppEventHistorySink(self.app_event_tx.clone()); + controller.push_and_maybe_commit(&delta, &sink); + } self.request_redraw(); } @@ -754,7 +762,7 @@ impl ChatWidget { active_exec_cell: None, config: config.clone(), auth_manager, - session_header: SessionHeader::new(config.model.clone()), + session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -762,7 +770,7 @@ impl ChatWidget { token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), - stream: StreamController::new(config), + stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, interrupts: InterruptManager::new(), @@ -813,7 +821,7 @@ impl ChatWidget { active_exec_cell: None, config: config.clone(), auth_manager, - session_header: SessionHeader::new(config.model.clone()), + session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -821,7 +829,7 @@ impl ChatWidget { token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), - stream: StreamController::new(config), + stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, interrupts: InterruptManager::new(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c009cabd94..c867155bdb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -320,12 +320,12 @@ fn make_chatwidget_manual() -> ( active_exec_cell: None, config: cfg.clone(), auth_manager, - session_header: SessionHeader::new(cfg.model.clone()), + session_header: SessionHeader::new(cfg.model), initial_user_message: None, token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), - stream: StreamController::new(cfg), + stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, interrupts: InterruptManager::new(), @@ -2133,8 +2133,12 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { // 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: "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() }), + }); + chat.handle_codex_event(Event { id: "c1".into(), msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { @@ -2182,10 +2186,6 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { }); 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; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 92b42732c3..42295782e5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -552,18 +552,24 @@ mod tests { use codex_core::auth::write_auth_json; use codex_core::token_data::IdTokenInfo; use codex_core::token_data::TokenData; - fn make_config() -> Config { - // Create a unique CODEX_HOME per test to isolate auth.json writes. + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + fn get_next_codex_home() -> PathBuf { + static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); let mut codex_home = std::env::temp_dir(); let unique_suffix = format!( "codex_tui_test_{}_{}", std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed) ); codex_home.push(unique_suffix); + codex_home + } + + fn make_config() -> Config { + // Create a unique CODEX_HOME per test to isolate auth.json writes. + let codex_home = get_next_codex_home(); std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME"); Config::load_from_base_config_with_overrides( diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index a9428b891b..51331887e9 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -20,25 +20,11 @@ impl MarkdownStreamCollector { } } - /// Returns the number of logical lines that have already been committed - /// (i.e., previously returned from `commit_complete_lines`). - pub fn committed_count(&self) -> usize { - self.committed_line_count - } - pub fn clear(&mut self) { self.buffer.clear(); self.committed_line_count = 0; } - /// Replace the buffered content and mark that the first `committed_count` - /// logical lines are already committed. - pub fn replace_with_and_mark_committed(&mut self, s: &str, committed_count: usize) { - self.buffer.clear(); - self.buffer.push_str(s); - self.committed_line_count = committed_count; - } - pub fn push_delta(&mut self, delta: &str) { tracing::trace!("push_delta: {delta:?}"); self.buffer.push_str(delta); diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 490d1f6de9..89b7ab95d3 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -3,7 +3,6 @@ use crate::history_cell::HistoryCell; use codex_core::config::Config; use ratatui::text::Line; -use super::HeaderEmitter; use super::StreamState; /// Sink for history insertions and animation control. @@ -36,56 +35,25 @@ type Lines = Vec>; /// commit animation across streams. pub(crate) struct StreamController { config: Config, - header: HeaderEmitter, state: StreamState, - active: bool, finishing_after_drain: bool, + header_emitted: bool, } impl StreamController { pub(crate) fn new(config: Config) -> Self { Self { config, - header: HeaderEmitter::new(), state: StreamState::new(), - active: false, finishing_after_drain: false, + header_emitted: false, } } - pub(crate) fn reset_headers_for_new_turn(&mut self) { - self.header.reset_for_new_turn(); - } - - pub(crate) fn is_write_cycle_active(&self) -> bool { - self.active - } - - pub(crate) fn clear_all(&mut self) { - self.state.clear(); - self.active = false; - self.finishing_after_drain = false; - // leave header state unchanged; caller decides when to reset - } - - /// 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. - if !self.active { - self.header.reset_for_stream(); - } - self.finishing_after_drain = false; - self.active = true; - } - /// Push a delta; if it contains a newline, commit completed lines and start animation. pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) { - if !self.active { - return; - } let cfg = self.config.clone(); let state = &mut self.state; - // Record that at least one delta was received for this stream if !delta.is_empty() { state.has_seen_delta = true; } @@ -99,117 +67,54 @@ impl StreamController { } } - /// Finalize the active stream. If `flush_immediately` is true, drain and emit now. - pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool { - if !self.active { - return false; - } + /// Finalize the active stream. Drain and emit now. + pub(crate) fn finalize(&mut self, sink: &impl HistorySink) { let cfg = self.config.clone(); // Finalize collector first. let remaining = { let state = &mut self.state; state.collector.finalize_and_drain(&cfg) }; - if flush_immediately { - // Collect all output first to avoid emitting headers when there is no content. - let mut out_lines: Lines = Vec::new(); - { - let state = &mut self.state; - if !remaining.is_empty() { - state.enqueue(remaining); - } - let step = state.drain_all(); - out_lines.extend(step.history); - } - if !out_lines.is_empty() { - // 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 - self.state.clear(); - // Allow a subsequent block in this turn to emit its header. - self.header.allow_reemit_in_turn(); - // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(); - self.active = false; - self.finishing_after_drain = false; - true - } else { + // Collect all output first to avoid emitting headers when there is no content. + let mut out_lines: Lines = Vec::new(); + { + let state = &mut self.state; if !remaining.is_empty() { - let state = &mut self.state; state.enqueue(remaining); } - // Spacer animated out - self.state.enqueue(vec![Line::from("")]); - self.finishing_after_drain = true; - sink.start_commit_animation(); - false + let step = state.drain_all(); + out_lines.extend(step.history); } + if !out_lines.is_empty() { + // Insert as a HistoryCell so display drops the header while transcript keeps it. + self.emit(sink, out_lines); + } + + // Cleanup + self.state.clear(); + self.finishing_after_drain = false; } /// Step animation: commit at most one queued line and handle end-of-drain cleanup. pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool { - if !self.active { - return false; - } let step = { self.state.step() }; if !step.history.is_empty() { - sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new( - step.history, - self.header.maybe_emit_header(), - ))); + self.emit(sink, step.history); } let is_idle = self.state.is_idle(); if is_idle { sink.stop_commit_animation(); - if self.finishing_after_drain { - // Reset and notify - self.state.clear(); - // Allow a subsequent block in this turn to emit its header. - self.header.allow_reemit_in_turn(); - // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(); - self.active = false; - self.finishing_after_drain = false; - return true; - } } false } - /// Apply a full final answer: replace queued content with only the remaining tail, - /// then finalize immediately and notify completion. - pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.apply_full_final(message, sink) - } - - fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.begin(sink); - - { - let state = &mut self.state; - // Only inject the final full message if we have not seen any deltas for this stream. - // If deltas were received, rely on the collector's existing buffer to avoid duplication. - if !state.has_seen_delta && !message.is_empty() { - // normalize to end with newline - let mut msg = message.to_owned(); - if !msg.ends_with('\n') { - msg.push('\n'); - } - - // replace while preserving already committed count - let committed = state.collector.committed_count(); - state - .collector - .replace_with_and_mark_committed(&msg, committed); - } - } - self.finalize(true, sink) + fn emit(&mut self, sink: &impl HistorySink, lines: Vec>) { + sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new( + lines, + !self.header_emitted, + ))); + self.header_emitted = true; } } @@ -268,7 +173,6 @@ mod tests { let cfg = test_config(); let mut ctrl = StreamController::new(cfg.clone()); let sink = TestSink::new(); - ctrl.begin(&sink); // Exact deltas from the session log (section: Loose vs. tight list items) let deltas = vec![ @@ -347,7 +251,7 @@ mod tests { let _ = ctrl.on_commit_tick(&sink); } // Finalize and flush remaining lines now. - let _ = ctrl.finalize(true, &sink); + ctrl.finalize(&sink); // Flatten sink output and strip the header that the controller inserts (blank + "codex"). let mut flat: Vec> = Vec::new(); diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index 3fc81360ba..dbb260bc68 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -34,41 +34,3 @@ impl StreamState { self.streamer.enqueue(lines) } } - -pub(crate) struct HeaderEmitter { - emitted_this_turn: bool, - emitted_in_stream: bool, -} - -impl HeaderEmitter { - pub(crate) fn new() -> Self { - Self { - emitted_this_turn: false, - emitted_in_stream: false, - } - } - - pub(crate) fn reset_for_new_turn(&mut self) { - self.emitted_this_turn = false; - self.emitted_in_stream = false; - } - - pub(crate) fn reset_for_stream(&mut self) { - self.emitted_in_stream = false; - } - - /// Allow emitting the header again within the current turn after a finalize. - pub(crate) fn allow_reemit_in_turn(&mut self) { - self.emitted_this_turn = false; - } - - pub(crate) fn maybe_emit_header(&mut self) -> bool { - if !self.emitted_in_stream && !self.emitted_this_turn { - self.emitted_in_stream = true; - self.emitted_this_turn = true; - true - } else { - false - } - } -} From 76a9b1167848c9c774bd59eb374ee1c96dcbc3fb Mon Sep 17 00:00:00 2001 From: friel-openai Date: Mon, 22 Sep 2025 11:16:25 -0700 Subject: [PATCH 32/42] Tui: fix backtracking (#4020) Backtracking multiple times could drop earlier turns. We now derive the active user-turn positions from the transcript on demand (keying off the latest session header) instead of caching state. This keeps the replayed context intact during repeated edits and adds a regression test. --- codex-rs/tui/src/app.rs | 71 +++++++++++++++++ codex-rs/tui/src/app_backtrack.rs | 123 ++++++++++++++++++------------ 2 files changed, 145 insertions(+), 49 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 138d65a052..e9766cb342 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -443,11 +443,20 @@ impl App { mod tests { use super::*; use crate::app_backtrack::BacktrackState; + use crate::app_backtrack::user_count; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::file_search::FileSearchManager; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use crate::history_cell::UserHistoryCell; + use crate::history_cell::new_session_info; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; + use codex_core::protocol::SessionConfiguredEvent; + use codex_protocol::mcp_protocol::ConversationId; + use ratatui::prelude::Line; + use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -498,4 +507,66 @@ mod tests { Some(ReasoningEffortConfig::High) ); } + + #[test] + fn backtrack_selection_with_duplicate_history_targets_unique_turn() { + let mut app = make_test_app(); + + let user_cell = |text: &str| -> Arc { + Arc::new(UserHistoryCell { + message: text.to_string(), + }) as Arc + }; + let agent_cell = |text: &str| -> Arc { + Arc::new(AgentMessageCell::new( + vec![Line::from(text.to_string())], + true, + )) as Arc + }; + + let make_header = |is_first| { + let event = SessionConfiguredEvent { + session_id: ConversationId::new(), + model: "gpt-test".to_string(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }; + Arc::new(new_session_info( + app.chat_widget.config_ref(), + event, + is_first, + )) as Arc + }; + + // Simulate the transcript after trimming for a fork, replaying history, and + // appending the edited turn. The session header separates the retained history + // from the forked conversation's replayed turns. + app.transcript_cells = vec![ + make_header(true), + user_cell("first question"), + agent_cell("answer first"), + user_cell("follow-up"), + agent_cell("answer follow-up"), + make_header(false), + user_cell("first question"), + agent_cell("answer first"), + user_cell("follow-up (edited)"), + agent_cell("answer edited"), + ]; + + assert_eq!(user_count(&app.transcript_cells), 2); + + app.backtrack.base_id = Some(ConversationId::new()); + app.backtrack.primed = true; + app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); + + app.confirm_backtrack_from_main(); + + let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); + assert_eq!(nth, 1); + assert_eq!(prefill, "follow-up (edited)"); + } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index c07be57af1..cbea2c02f7 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1,7 +1,9 @@ +use std::any::TypeId; use std::path::PathBuf; use std::sync::Arc; use crate::app::App; +use crate::history_cell::CompositeHistoryCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; @@ -160,43 +162,47 @@ impl App { self.backtrack.primed = true; self.backtrack.base_id = self.chat_widget.conversation_id(); self.backtrack.overlay_preview_active = true; - let last_user_cell_position = self - .transcript_cells - .iter() - .filter_map(|c| c.as_any().downcast_ref::()) - .count() as i64 - - 1; - if last_user_cell_position >= 0 { - self.apply_backtrack_selection(last_user_cell_position as usize); + let count = user_count(&self.transcript_cells); + if let Some(last) = count.checked_sub(1) { + self.apply_backtrack_selection(last); } tui.frame_requester().schedule_frame(); } /// Step selection to the next older user message and update overlay. fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let last_user_cell_position = self - .transcript_cells - .iter() - .filter(|c| c.as_any().is::()) - .take(self.backtrack.nth_user_message) - .count() - .saturating_sub(1); - self.apply_backtrack_selection(last_user_cell_position); + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else if self.backtrack.nth_user_message == 0 { + 0 + } else { + self.backtrack + .nth_user_message + .saturating_sub(1) + .min(last_index) + }; + + self.apply_backtrack_selection(next_selection); tui.frame_requester().schedule_frame(); } /// Apply a computed backtrack selection to the overlay and internal counter. fn apply_backtrack_selection(&mut self, nth_user_message: usize) { - self.backtrack.nth_user_message = nth_user_message; - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - let cell = self - .transcript_cells - .iter() - .enumerate() - .filter(|(_, c)| c.as_any().is::()) - .nth(nth_user_message); - if let Some((idx, _)) = cell { - t.set_highlight_cell(Some(idx)); + if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { + self.backtrack.nth_user_message = nth_user_message; + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.set_highlight_cell(Some(cell_idx)); + } + } else { + self.backtrack.nth_user_message = usize::MAX; + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.set_highlight_cell(None); } } } @@ -217,13 +223,9 @@ impl App { fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; if let Some(base_id) = self.backtrack.base_id { - let user_cells = self - .transcript_cells - .iter() - .filter_map(|c| c.as_any().downcast_ref::()) - .collect::>(); - let prefill = user_cells - .get(nth_user_message) + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) .map(|c| c.message.clone()) .unwrap_or_default(); self.close_transcript_overlay(tui); @@ -246,14 +248,12 @@ impl App { /// Computes the prefill from the selected user message and requests history. pub(crate) fn confirm_backtrack_from_main(&mut self) { if let Some(base_id) = self.backtrack.base_id { - let prefill = self - .transcript_cells - .iter() - .filter(|c| c.as_any().is::()) - .nth(self.backtrack.nth_user_message) - .and_then(|c| c.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); + let prefill = + nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); } self.reset_backtrack_state(); @@ -363,13 +363,41 @@ fn trim_transcript_cells_to_nth_user( return; } - let cut_idx = transcript_cells + if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { + transcript_cells.truncate(cut_idx); + } +} + +pub(crate) fn user_count(cells: &[Arc]) -> usize { + user_positions_iter(cells).count() +} + +fn nth_user_position( + cells: &[Arc], + nth: usize, +) -> Option { + user_positions_iter(cells) + .enumerate() + .find_map(|(i, idx)| (i == nth).then_some(idx)) +} + +fn user_positions_iter( + cells: &[Arc], +) -> impl Iterator + '_ { + let header_type = TypeId::of::(); + let user_type = TypeId::of::(); + let type_of = |cell: &Arc| cell.as_any().type_id(); + + let start = cells + .iter() + .rposition(|cell| type_of(cell) == header_type) + .map_or(0, |idx| idx + 1); + + cells .iter() .enumerate() - .filter_map(|(idx, cell)| cell.as_any().is::().then_some(idx)) - .nth(nth_user_message) - .unwrap_or(transcript_cells.len()); - transcript_cells.truncate(cut_idx); + .skip(start) + .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } #[cfg(test)] @@ -389,7 +417,6 @@ mod tests { Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); assert!(cells.is_empty()); @@ -406,7 +433,6 @@ mod tests { Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); assert_eq!(cells.len(), 1); @@ -440,7 +466,6 @@ mod tests { Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, ]; - trim_transcript_cells_to_nth_user(&mut cells, 1); assert_eq!(cells.len(), 3); From d2940bd4c3a1440d7f04ddccd4659ff62cd40b14 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Sep 2025 11:23:05 -0700 Subject: [PATCH 33/42] Remove /limits after moving to /status (#4055) Moved to /status #4053 --- codex-rs/tui/src/chatwidget.rs | 16 +- ...chatwidget__tests__limits_placeholder.snap | 10 - ...twidget__tests__limits_snapshot_basic.snap | 29 - ...sts__limits_snapshot_hourly_remaining.snap | 29 - ...t__tests__limits_snapshot_mixed_usage.snap | 29 - ...__tests__limits_snapshot_weekly_heavy.snap | 29 - codex-rs/tui/src/chatwidget/tests.rs | 112 ---- codex-rs/tui/src/history_cell.rs | 37 -- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/rate_limits_view.rs | 504 ------------------ codex-rs/tui/src/slash_command.rs | 3 - 11 files changed, 2 insertions(+), 797 deletions(-) delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap delete mode 100644 codex-rs/tui/src/rate_limits_view.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0702eff564..bbcc3df273 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -128,7 +128,7 @@ impl RateLimitWarningState { { let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]; warnings.push(format!( - "Weekly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + "Weekly usage exceeded {threshold:.0}% of the limit. Check /status to review usage." )); self.weekly_index += 1; } @@ -138,7 +138,7 @@ impl RateLimitWarningState { { let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]; warnings.push(format!( - "Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + "Hourly usage exceeded {threshold:.0}% of the limit. Check /status to review usage." )); self.hourly_index += 1; } @@ -996,9 +996,6 @@ impl ChatWidget { SlashCommand::Status => { self.add_status_output(); } - SlashCommand::Limits => { - self.add_limits_output(); - } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -1355,15 +1352,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_limits_output(&mut self) { - if let Some(snapshot) = &self.rate_limit_snapshot { - self.add_to_history(history_cell::new_limits_output(snapshot)); - } else { - self.add_to_history(history_cell::new_limits_unavailable()); - } - self.request_redraw(); - } - pub(crate) fn add_status_output(&mut self) { let default_usage; let usage_ref = if let Some(ti) = &self.token_info { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap deleted file mode 100644 index fa37b2201f..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -[magenta]/limits[/] - -[bold]Rate limit usage snapshot[/] -[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] - Real usage data is not available yet. -[dim] Send a message to Codex, then run /limits again.[/] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap deleted file mode 100644 index ced4466837..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -[magenta]/limits[/] - -[bold]Rate limit usage snapshot[/] -[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] - • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]30.0% used[/] - • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]60.0% used[/] -[green] Within current limits[/] - -[dim] ╭─────────────────────────────────────────────────╮[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] - [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] - [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] -[dim] ╰─────────────────────────────────────────────────╯[/] - -[bold]Legend[/] - • [dark-gray+bold]Dark gray[/] = weekly usage so far - • [green+bold]Green[/] = hourly capacity still available - • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap deleted file mode 100644 index defc5f213c..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -[magenta]/limits[/] - -[bold]Rate limit usage snapshot[/] -[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] - • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]0.0% used[/] - • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] -[green] Within current limits[/] - -[dim] ╭─────────────────────────────────────────────────╮[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] -[dim] ╰─────────────────────────────────────────────────╯[/] - -[bold]Legend[/] - • [dark-gray+bold]Dark gray[/] = weekly usage so far - • [green+bold]Green[/] = hourly capacity still available - • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap deleted file mode 100644 index 86c82d9247..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -[magenta]/limits[/] - -[bold]Rate limit usage snapshot[/] -[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] - • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]20.0% used[/] - • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] -[green] Within current limits[/] - -[dim] ╭─────────────────────────────────────────────────╮[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] - [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] -[dim] ╰─────────────────────────────────────────────────╯[/] - -[bold]Legend[/] - • [dark-gray+bold]Dark gray[/] = weekly usage so far - • [green+bold]Green[/] = hourly capacity still available - • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap deleted file mode 100644 index a1650545b6..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -[magenta]/limits[/] - -[bold]Rate limit usage snapshot[/] -[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] - • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]98.0% used[/] - • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]0.0% used[/] -[green] Within current limits[/] - -[dim] ╭─────────────────────────────────────────────────╮[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] - [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] -[dim] ╰─────────────────────────────────────────────────╯[/] - -[bold]Legend[/] - • [dark-gray+bold]Dark gray[/] = weekly usage so far - • [green+bold]Green[/] = hourly capacity still available - • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c867155bdb..028d909b00 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -25,7 +25,6 @@ use codex_core::protocol::InputMessageKind; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::ReviewCodeLocation; use codex_core::protocol::ReviewFinding; use codex_core::protocol::ReviewLineRange; @@ -40,8 +39,6 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; -use ratatui::style::Color; -use ratatui::style::Modifier; use std::fs::File; use std::io::BufRead; use std::io::BufReader; @@ -380,115 +377,6 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } -fn styled_lines_to_string(lines: &[ratatui::text::Line<'static>]) -> String { - let mut out = String::new(); - for line in lines { - for span in &line.spans { - let mut tags: Vec<&str> = Vec::new(); - if let Some(color) = span.style.fg { - let name = match color { - Color::Black => "black", - Color::Blue => "blue", - Color::Cyan => "cyan", - Color::DarkGray => "dark-gray", - Color::Gray => "gray", - Color::Green => "green", - Color::LightBlue => "light-blue", - Color::LightCyan => "light-cyan", - Color::LightGreen => "light-green", - Color::LightMagenta => "light-magenta", - Color::LightRed => "light-red", - Color::LightYellow => "light-yellow", - Color::Magenta => "magenta", - Color::Red => "red", - Color::Rgb(_, _, _) => "rgb", - Color::Indexed(_) => "indexed", - Color::Reset => "reset", - Color::Yellow => "yellow", - Color::White => "white", - }; - tags.push(name); - } - let modifiers = span.style.add_modifier; - if modifiers.contains(Modifier::BOLD) { - tags.push("bold"); - } - if modifiers.contains(Modifier::DIM) { - tags.push("dim"); - } - if modifiers.contains(Modifier::ITALIC) { - tags.push("italic"); - } - if modifiers.contains(Modifier::UNDERLINED) { - tags.push("underlined"); - } - if !tags.is_empty() { - out.push('['); - out.push_str(&tags.join("+")); - out.push(']'); - } - out.push_str(&span.content); - if !tags.is_empty() { - out.push_str("[/]"); - } - } - out.push('\n'); - } - out -} - -fn sample_rate_limit_snapshot( - primary_used_percent: f64, - weekly_used_percent: f64, - ratio_percent: f64, -) -> RateLimitSnapshotEvent { - RateLimitSnapshotEvent { - primary_used_percent, - weekly_used_percent, - primary_to_weekly_ratio_percent: ratio_percent, - primary_window_minutes: 300, - weekly_window_minutes: 10_080, - } -} - -fn capture_limits_snapshot(snapshot: Option) -> String { - let lines = match snapshot { - Some(ref snapshot) => history_cell::new_limits_output(snapshot).display_lines(80), - None => history_cell::new_limits_unavailable().display_lines(80), - }; - styled_lines_to_string(&lines) -} - -#[test] -fn limits_placeholder() { - let visual = capture_limits_snapshot(None); - assert_snapshot!(visual); -} - -#[test] -fn limits_snapshot_basic() { - let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(30.0, 60.0, 40.0))); - assert_snapshot!(visual); -} - -#[test] -fn limits_snapshot_hourly_remaining() { - let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(0.0, 20.0, 10.0))); - assert_snapshot!(visual); -} - -#[test] -fn limits_snapshot_mixed_usage() { - let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(20.0, 20.0, 10.0))); - assert_snapshot!(visual); -} - -#[test] -fn limits_snapshot_weekly_heavy() { - let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(98.0, 0.0, 10.0))); - assert_snapshot!(visual); -} - #[test] fn rate_limit_warnings_emit_thresholds() { let mut state = RateLimitWarningState::default(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 89d3fd6903..d4697218c5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2,9 +2,6 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; -use crate::rate_limits_view::DEFAULT_GRID_CONFIG; -use crate::rate_limits_view::LimitsView; -use crate::rate_limits_view::build_limits_view; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; @@ -229,20 +226,6 @@ impl HistoryCell for PlainHistoryCell { } } -#[derive(Debug)] -pub(crate) struct LimitsHistoryCell { - display: LimitsView, -} - -impl HistoryCell for LimitsHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut lines = self.display.summary_lines.clone(); - lines.extend(self.display.gauge_lines(width)); - lines.extend(self.display.legend_lines.clone()); - lines - } -} - #[derive(Debug)] pub(crate) struct TranscriptOnlyHistoryCell { lines: Vec>, @@ -1096,26 +1079,6 @@ pub(crate) fn new_completed_mcp_tool_call( Box::new(PlainHistoryCell { lines }) } -pub(crate) fn new_limits_output(snapshot: &RateLimitSnapshotEvent) -> LimitsHistoryCell { - LimitsHistoryCell { - display: build_limits_view(snapshot, DEFAULT_GRID_CONFIG), - } -} - -pub(crate) fn new_limits_unavailable() -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![ - "/limits".magenta().into(), - "".into(), - vec!["Rate limit usage snapshot".bold()].into(), - vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()] - .into(), - vec![" Real usage data is not available yet.".into()].into(), - vec![" Send a message to Codex, then run /limits again.".dim()].into(), - ], - } -} - #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell { PlainHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 42295782e5..83aa2e2368 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -55,7 +55,6 @@ mod markdown_stream; mod new_model_popup; pub mod onboarding; mod pager_overlay; -mod rate_limits_view; mod render; mod resume_picker; mod session_log; diff --git a/codex-rs/tui/src/rate_limits_view.rs b/codex-rs/tui/src/rate_limits_view.rs deleted file mode 100644 index 72fc6e9763..0000000000 --- a/codex-rs/tui/src/rate_limits_view.rs +++ /dev/null @@ -1,504 +0,0 @@ -use codex_core::protocol::RateLimitSnapshotEvent; -use ratatui::prelude::*; -use ratatui::style::Stylize; - -/// Aggregated output used by the `/limits` command. -/// It contains the rendered summary lines, optional legend, -/// and the precomputed gauge state when one can be shown. -#[derive(Debug)] -pub(crate) struct LimitsView { - pub(crate) summary_lines: Vec>, - pub(crate) legend_lines: Vec>, - grid_state: Option, - grid: GridConfig, -} - -impl LimitsView { - /// Render the gauge for the provided width if the data supports it. - pub(crate) fn gauge_lines(&self, width: u16) -> Vec> { - match self.grid_state { - Some(state) => render_limit_grid(state, self.grid, width), - None => Vec::new(), - } - } -} - -/// Configuration for the simple grid gauge rendered by `/limits`. -#[derive(Clone, Copy, Debug)] -pub(crate) struct GridConfig { - pub(crate) weekly_slots: usize, - pub(crate) logo: &'static str, -} - -/// Default gauge configuration used by the TUI. -pub(crate) const DEFAULT_GRID_CONFIG: GridConfig = GridConfig { - weekly_slots: 100, - logo: "(>_)", -}; - -/// Build the lines and optional gauge used by the `/limits` view. -pub(crate) fn build_limits_view( - snapshot: &RateLimitSnapshotEvent, - grid_config: GridConfig, -) -> LimitsView { - let metrics = RateLimitMetrics::from_snapshot(snapshot); - let grid_state = extract_capacity_fraction(snapshot) - .and_then(|fraction| compute_grid_state(&metrics, fraction)) - .map(|state| scale_grid_state(state, grid_config)); - - LimitsView { - summary_lines: build_summary_lines(&metrics), - legend_lines: build_legend_lines(grid_state.is_some()), - grid_state, - grid: grid_config, - } -} - -#[derive(Debug)] -struct RateLimitMetrics { - hourly_used: f64, - weekly_used: f64, - hourly_remaining: f64, - weekly_remaining: f64, - hourly_window_label: String, - weekly_window_label: String, - hourly_reset_hint: String, - weekly_reset_hint: String, -} - -impl RateLimitMetrics { - fn from_snapshot(snapshot: &RateLimitSnapshotEvent) -> Self { - let hourly_used = snapshot.primary_used_percent.clamp(0.0, 100.0); - let weekly_used = snapshot.weekly_used_percent.clamp(0.0, 100.0); - Self { - hourly_used, - weekly_used, - hourly_remaining: (100.0 - hourly_used).max(0.0), - weekly_remaining: (100.0 - weekly_used).max(0.0), - hourly_window_label: format_window_label(Some(snapshot.primary_window_minutes)), - weekly_window_label: format_window_label(Some(snapshot.weekly_window_minutes)), - hourly_reset_hint: format_reset_hint(Some(snapshot.primary_window_minutes)), - weekly_reset_hint: format_reset_hint(Some(snapshot.weekly_window_minutes)), - } - } - - fn hourly_exhausted(&self) -> bool { - self.hourly_remaining <= 0.0 - } - - fn weekly_exhausted(&self) -> bool { - self.weekly_remaining <= 0.0 - } -} - -fn format_window_label(minutes: Option) -> String { - approximate_duration(minutes) - .map(|(value, unit)| format!("≈{value} {} window", pluralize_unit(unit, value))) - .unwrap_or_else(|| "window unknown".to_string()) -} - -fn format_reset_hint(minutes: Option) -> String { - approximate_duration(minutes) - .map(|(value, unit)| format!("≈{value} {}", pluralize_unit(unit, value))) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn approximate_duration(minutes: Option) -> Option<(u64, DurationUnit)> { - let minutes = minutes?; - if minutes == 0 { - return Some((1, DurationUnit::Minute)); - } - if minutes < 60 { - return Some((minutes, DurationUnit::Minute)); - } - if minutes < 1_440 { - let hours = ((minutes as f64) / 60.0).round().max(1.0) as u64; - return Some((hours, DurationUnit::Hour)); - } - let days = ((minutes as f64) / 1_440.0).round().max(1.0) as u64; - if days >= 7 { - let weeks = ((days as f64) / 7.0).round().max(1.0) as u64; - Some((weeks, DurationUnit::Week)) - } else { - Some((days, DurationUnit::Day)) - } -} - -fn pluralize_unit(unit: DurationUnit, value: u64) -> String { - match unit { - DurationUnit::Minute => { - if value == 1 { - "minute".to_string() - } else { - "minutes".to_string() - } - } - DurationUnit::Hour => { - if value == 1 { - "hour".to_string() - } else { - "hours".to_string() - } - } - DurationUnit::Day => { - if value == 1 { - "day".to_string() - } else { - "days".to_string() - } - } - DurationUnit::Week => { - if value == 1 { - "week".to_string() - } else { - "weeks".to_string() - } - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DurationUnit { - Minute, - Hour, - Day, - Week, -} - -#[derive(Clone, Copy, Debug)] -struct GridState { - weekly_used_ratio: f64, - hourly_remaining_ratio: f64, -} - -fn build_summary_lines(metrics: &RateLimitMetrics) -> Vec> { - let mut lines: Vec> = vec![ - "/limits".magenta().into(), - "".into(), - vec!["Rate limit usage snapshot".bold()].into(), - vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()].into(), - build_usage_line( - " • Hourly limit", - &metrics.hourly_window_label, - metrics.hourly_used, - ), - build_usage_line( - " • Weekly limit", - &metrics.weekly_window_label, - metrics.weekly_used, - ), - ]; - lines.push(build_status_line(metrics)); - lines -} - -fn build_usage_line(label: &str, window_label: &str, used_percent: f64) -> Line<'static> { - Line::from(vec![ - label.to_string().into(), - format!(" ({window_label})").dim(), - ": ".into(), - format!("{used_percent:.1}% used").dark_gray().bold(), - ]) -} - -fn build_status_line(metrics: &RateLimitMetrics) -> Line<'static> { - let mut spans: Vec> = Vec::new(); - if metrics.weekly_exhausted() || metrics.hourly_exhausted() { - spans.push(" Rate limited: ".into()); - let reason = match (metrics.hourly_exhausted(), metrics.weekly_exhausted()) { - (true, true) => "weekly and hourly windows exhausted", - (true, false) => "hourly window exhausted", - (false, true) => "weekly window exhausted", - (false, false) => unreachable!(), - }; - spans.push(reason.red()); - if metrics.hourly_exhausted() { - spans.push(" — hourly resets in ".into()); - spans.push(metrics.hourly_reset_hint.clone().dim()); - } - if metrics.weekly_exhausted() { - spans.push(" — weekly resets in ".into()); - spans.push(metrics.weekly_reset_hint.clone().dim()); - } - } else { - spans.push(" Within current limits".green()); - } - Line::from(spans) -} - -fn build_legend_lines(show_gauge: bool) -> Vec> { - if !show_gauge { - return Vec::new(); - } - vec![ - vec!["Legend".bold()].into(), - vec![ - " • ".into(), - "Dark gray".dark_gray().bold(), - " = weekly usage so far".into(), - ] - .into(), - vec![ - " • ".into(), - "Green".green().bold(), - " = hourly capacity still available".into(), - ] - .into(), - vec![ - " • ".into(), - "Default".bold(), - " = weekly capacity beyond the hourly window".into(), - ] - .into(), - ] -} - -fn extract_capacity_fraction(snapshot: &RateLimitSnapshotEvent) -> Option { - let ratio = snapshot.primary_to_weekly_ratio_percent; - if ratio.is_finite() { - Some((ratio / 100.0).clamp(0.0, 1.0)) - } else { - None - } -} - -fn compute_grid_state(metrics: &RateLimitMetrics, capacity_fraction: f64) -> Option { - if capacity_fraction <= 0.0 { - return None; - } - - let weekly_used_ratio = (metrics.weekly_used / 100.0).clamp(0.0, 1.0); - let weekly_remaining_ratio = (1.0 - weekly_used_ratio).max(0.0); - - let hourly_used_ratio = (metrics.hourly_used / 100.0).clamp(0.0, 1.0); - let hourly_used_within_capacity = - (hourly_used_ratio * capacity_fraction).min(capacity_fraction); - let hourly_remaining_within_capacity = - (capacity_fraction - hourly_used_within_capacity).max(0.0); - - let hourly_remaining_ratio = hourly_remaining_within_capacity.min(weekly_remaining_ratio); - - Some(GridState { - weekly_used_ratio, - hourly_remaining_ratio, - }) -} - -fn scale_grid_state(state: GridState, grid: GridConfig) -> GridState { - if grid.weekly_slots == 0 { - return GridState { - weekly_used_ratio: 0.0, - hourly_remaining_ratio: 0.0, - }; - } - state -} - -/// Convert the grid state to rendered lines for the TUI. -fn render_limit_grid(state: GridState, grid_config: GridConfig, width: u16) -> Vec> { - GridLayout::new(grid_config, width) - .map(|layout| layout.render(state)) - .unwrap_or_default() -} - -/// Precomputed layout information for the usage grid. -struct GridLayout { - size: usize, - inner_width: usize, - config: GridConfig, -} - -impl GridLayout { - const MIN_SIDE: usize = 4; - const MAX_SIDE: usize = 12; - const PREFIX: &'static str = " "; - - fn new(config: GridConfig, width: u16) -> Option { - if config.weekly_slots == 0 || config.logo.is_empty() { - return None; - } - let cell_width = config.logo.chars().count(); - if cell_width == 0 { - return None; - } - - let available_inner = width.saturating_sub((Self::PREFIX.len() + 2) as u16) as usize; - if available_inner == 0 { - return None; - } - - let base_side = (config.weekly_slots as f64) - .sqrt() - .round() - .clamp(1.0, Self::MAX_SIDE as f64) as usize; - let width_limited_side = - ((available_inner + 1) / (cell_width + 1)).clamp(1, Self::MAX_SIDE); - - let mut side = base_side.min(width_limited_side); - if width_limited_side >= Self::MIN_SIDE { - side = side.max(Self::MIN_SIDE.min(width_limited_side)); - } - let side = side.clamp(1, Self::MAX_SIDE); - if side == 0 { - return None; - } - - let inner_width = side * cell_width + side.saturating_sub(1); - Some(Self { - size: side, - inner_width, - config, - }) - } - - /// Render the grid into styled lines for the history cell. - fn render(&self, state: GridState) -> Vec> { - let counts = self.cell_counts(state); - let mut lines = Vec::new(); - lines.push("".into()); - lines.push(self.render_border('╭', '╮')); - - let mut cell_index = 0isize; - for _ in 0..self.size { - let mut spans: Vec> = Vec::new(); - spans.push(Self::PREFIX.into()); - spans.push("│".dim()); - - for col in 0..self.size { - if col > 0 { - spans.push(" ".into()); - } - let span = if cell_index < counts.dark_cells { - self.config.logo.dark_gray() - } else if cell_index < counts.dark_cells + counts.green_cells { - self.config.logo.green() - } else { - self.config.logo.into() - }; - spans.push(span); - cell_index += 1; - } - - spans.push("│".dim()); - lines.push(Line::from(spans)); - } - - lines.push(self.render_border('╰', '╯')); - lines.push("".into()); - - if counts.white_cells == 0 { - lines.push(vec![" (No unused weekly capacity remaining)".dim()].into()); - lines.push("".into()); - } - - lines - } - - fn render_border(&self, left: char, right: char) -> Line<'static> { - let mut text = String::from(Self::PREFIX); - text.push(left); - text.push_str(&"─".repeat(self.inner_width)); - text.push(right); - vec![Span::from(text).dim()].into() - } - - /// Translate usage ratios into the number of coloured cells. - fn cell_counts(&self, state: GridState) -> GridCellCounts { - let total_cells = self.size * self.size; - let mut dark_cells = (state.weekly_used_ratio * total_cells as f64).round() as isize; - dark_cells = dark_cells.clamp(0, total_cells as isize); - let mut green_cells = (state.hourly_remaining_ratio * total_cells as f64).round() as isize; - if dark_cells + green_cells > total_cells as isize { - green_cells = (total_cells as isize - dark_cells).max(0); - } - let white_cells = (total_cells as isize - dark_cells - green_cells).max(0); - - GridCellCounts { - dark_cells, - green_cells, - white_cells, - } - } -} - -/// Number of weekly (dark), hourly (green) and unused (default) cells. -struct GridCellCounts { - dark_cells: isize, - green_cells: isize, - white_cells: isize, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn snapshot() -> RateLimitSnapshotEvent { - RateLimitSnapshotEvent { - primary_used_percent: 30.0, - weekly_used_percent: 60.0, - primary_to_weekly_ratio_percent: 40.0, - primary_window_minutes: 300, - weekly_window_minutes: 10_080, - } - } - - #[test] - fn approximate_duration_handles_hours_and_weeks() { - assert_eq!( - approximate_duration(Some(299)), - Some((5, DurationUnit::Hour)) - ); - assert_eq!( - approximate_duration(Some(10_080)), - Some((1, DurationUnit::Week)) - ); - assert_eq!( - approximate_duration(Some(90)), - Some((2, DurationUnit::Hour)) - ); - } - - #[test] - fn build_display_constructs_summary_and_gauge() { - let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); - assert!(display.summary_lines.iter().any(|line| { - line.spans - .iter() - .any(|span| span.content.contains("Weekly limit")) - })); - assert!(display.summary_lines.iter().any(|line| { - line.spans - .iter() - .any(|span| span.content.contains("Hourly limit")) - })); - assert!(!display.gauge_lines(80).is_empty()); - } - - #[test] - fn hourly_and_weekly_percentages_are_not_swapped() { - let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); - let summary = display - .summary_lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); - - assert!(summary.contains("Hourly limit (≈5 hours window): 30.0% used")); - assert!(summary.contains("Weekly limit (≈1 week window): 60.0% used")); - } - - #[test] - fn build_display_without_ratio_skips_gauge() { - let mut s = snapshot(); - s.primary_to_weekly_ratio_percent = f64::NAN; - let display = build_limits_view(&s, DEFAULT_GRID_CONFIG); - assert!(display.gauge_lines(80).is_empty()); - assert!(display.legend_lines.is_empty()); - } -} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 6570cf685c..f043d62f46 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -21,7 +21,6 @@ pub enum SlashCommand { Diff, Mention, Status, - Limits, Mcp, Logout, Quit, @@ -41,7 +40,6 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Limits => "visualize weekly and hourly rate limits", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", @@ -70,7 +68,6 @@ impl SlashCommand { SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status - | SlashCommand::Limits | SlashCommand::Mcp | SlashCommand::Quit => true, From 8daba53808aaa9747f7ed6f9351cf401c13e80c6 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Mon, 22 Sep 2025 11:29:39 -0700 Subject: [PATCH 34/42] feat: Add view stack to BottomPane (#4026) Adds a "View Stack" to the bottom pane to allow for pushing/popping bottom panels. `esc` will go back instead of dismissing. Benefit: We retain the "selection state" of a parent panel (e.g. the review panel). --- codex-rs/tui/src/app.rs | 3 - codex-rs/tui/src/app_event.rs | 3 - .../src/bottom_pane/approval_modal_view.rs | 11 +- .../tui/src/bottom_pane/bottom_pane_view.rs | 7 +- .../tui/src/bottom_pane/custom_prompt_view.rs | 19 +--- .../src/bottom_pane/list_selection_view.rs | 13 +-- codex-rs/tui/src/bottom_pane/mod.rs | 100 +++++++++--------- codex-rs/tui/src/chatwidget.rs | 5 - codex-rs/tui/src/chatwidget/tests.rs | 18 +--- 9 files changed, 70 insertions(+), 109 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e9766cb342..9c30afdb4b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -363,9 +363,6 @@ impl App { AppEvent::OpenReviewCustomPrompt => { self.chat_widget.show_review_custom_prompt(); } - AppEvent::OpenReviewPopup => { - self.chat_widget.open_review_popup(); - } } Ok(true) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 52e9393d2d..56c66379a6 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -76,7 +76,4 @@ pub(crate) enum AppEvent { /// Open the custom prompt option from the review popup. OpenReviewCustomPrompt, - - /// Open the top-level review presets popup. - OpenReviewPopup, } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index e204051d28..912df6ce5a 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -7,7 +7,6 @@ use crate::app_event_sender::AppEventSender; use crate::user_approval_widget::ApprovalRequest; use crate::user_approval_widget::UserApprovalWidget; -use super::BottomPane; use super::BottomPaneView; use super::CancellationEvent; @@ -42,12 +41,12 @@ impl ApprovalModalView { } impl BottomPaneView for ApprovalModalView { - fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { + fn handle_key_event(&mut self, key_event: KeyEvent) { self.current.handle_key_event(key_event); self.maybe_advance(); } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { + fn on_ctrl_c(&mut self) -> CancellationEvent { self.current.on_ctrl_c(); self.queue.clear(); CancellationEvent::Handled @@ -75,6 +74,7 @@ impl BottomPaneView for ApprovalModalView { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::bottom_pane::BottomPane; use tokio::sync::mpsc::unbounded_channel; fn make_exec_request() -> ApprovalRequest { @@ -94,7 +94,8 @@ mod tests { view.enqueue_request(make_exec_request()); let (tx2, _rx2) = unbounded_channel::(); - let mut pane = BottomPane::new(super::super::BottomPaneParams { + // Why do we have this? + let _pane = BottomPane::new(super::super::BottomPaneParams { app_event_tx: AppEventSender::new(tx2), frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, @@ -102,7 +103,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, }); - assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); + assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); assert!(view.queue.is_empty()); assert!(view.current.is_complete()); assert!(view.is_complete()); diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index de1beaa278..6bc436f9dc 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -3,14 +3,13 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use super::BottomPane; use super::CancellationEvent; /// Trait implemented by every view that can be shown in the bottom pane. pub(crate) trait BottomPaneView { /// Handle a key event while the view is active. A redraw is always /// scheduled after this call. - fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {} + fn handle_key_event(&mut self, _key_event: KeyEvent) {} /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { @@ -18,7 +17,7 @@ pub(crate) trait BottomPaneView { } /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { + fn on_ctrl_c(&mut self) -> CancellationEvent { CancellationEvent::NotHandled } @@ -30,7 +29,7 @@ pub(crate) trait BottomPaneView { /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. - fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool { + fn handle_paste(&mut self, _pasted: String) -> bool { false } diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index b498d87811..a8084d0d04 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -13,8 +13,6 @@ use ratatui::widgets::Widget; use std::cell::RefCell; use super::popup_consts::STANDARD_POPUP_HINT_LINE; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::SelectionAction; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -30,8 +28,6 @@ pub(crate) struct CustomPromptView { placeholder: String, context_label: Option, on_submit: PromptSubmitted, - app_event_tx: AppEventSender, - on_escape: Option, // UI state textarea: TextArea, @@ -44,8 +40,6 @@ impl CustomPromptView { title: String, placeholder: String, context_label: Option, - app_event_tx: AppEventSender, - on_escape: Option, on_submit: PromptSubmitted, ) -> Self { Self { @@ -53,8 +47,6 @@ impl CustomPromptView { placeholder, context_label, on_submit, - app_event_tx, - on_escape, textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), complete: false, @@ -63,12 +55,12 @@ impl CustomPromptView { } impl BottomPaneView for CustomPromptView { - fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) { + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Esc, .. } => { - self.on_ctrl_c(_pane); + self.on_ctrl_c(); } KeyEvent { code: KeyCode::Enter, @@ -93,11 +85,8 @@ impl BottomPaneView for CustomPromptView { } } - fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> CancellationEvent { + fn on_ctrl_c(&mut self) -> CancellationEvent { self.complete = true; - if let Some(cb) = &self.on_escape { - cb(&self.app_event_tx); - } CancellationEvent::Handled } @@ -212,7 +201,7 @@ impl BottomPaneView for CustomPromptView { } } - fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool { + fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() { return false; } 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 82466bf796..a856a3c634 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -11,7 +11,6 @@ use ratatui::widgets::Widget; use crate::app_event_sender::AppEventSender; -use super::BottomPane; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::popup_consts::MAX_POPUP_ROWS; @@ -40,7 +39,6 @@ pub(crate) struct SelectionViewParams { pub items: Vec, pub is_searchable: bool, pub search_placeholder: Option, - pub on_escape: Option, } pub(crate) struct ListSelectionView { @@ -51,7 +49,6 @@ pub(crate) struct ListSelectionView { state: ScrollState, complete: bool, app_event_tx: AppEventSender, - on_escape: Option, is_searchable: bool, search_query: String, search_placeholder: Option, @@ -77,7 +74,6 @@ impl ListSelectionView { state: ScrollState::new(), complete: false, app_event_tx, - on_escape: params.on_escape, is_searchable: params.is_searchable, search_query: String::new(), search_placeholder: if params.is_searchable { @@ -221,7 +217,7 @@ impl ListSelectionView { } impl BottomPaneView for ListSelectionView { - fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { + fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Up, .. @@ -240,7 +236,7 @@ impl BottomPaneView for ListSelectionView { KeyEvent { code: KeyCode::Esc, .. } => { - self.on_ctrl_c(_pane); + self.on_ctrl_c(); } KeyEvent { code: KeyCode::Char(c), @@ -266,11 +262,8 @@ impl BottomPaneView for ListSelectionView { self.complete } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { + fn on_ctrl_c(&mut self) -> CancellationEvent { self.complete = true; - if let Some(cb) = &self.on_escape { - cb(&self.app_event_tx); - } CancellationEvent::Handled } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 06eff112bc..523043e6f3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -7,6 +7,7 @@ use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; @@ -51,8 +52,8 @@ pub(crate) struct BottomPane { /// input state is retained when the view is closed. composer: ChatComposer, - /// If present, this is displayed instead of the `composer` (e.g. modals). - active_view: Option>, + /// Stack of views displayed instead of the composer (e.g. popups/modals). + view_stack: Vec>, app_event_tx: AppEventSender, frame_requester: FrameRequester, @@ -89,7 +90,7 @@ impl BottomPane { params.placeholder_text, params.disable_paste_burst, ), - active_view: None, + view_stack: Vec::new(), app_event_tx: params.app_event_tx, frame_requester: params.frame_requester, has_input_focus: params.has_input_focus, @@ -101,12 +102,21 @@ impl BottomPane { } } + fn active_view(&self) -> Option<&dyn BottomPaneView> { + self.view_stack.last().map(|view| view.as_ref()) + } + + fn push_view(&mut self, view: Box) { + self.view_stack.push(view); + self.request_redraw(); + } + pub fn desired_height(&self, width: u16) -> u16 { // 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 base = match self.active_view.as_ref() { + let base = match self.active_view().as_ref() { Some(view) => view.desired_height(width), None => self.composer.desired_height(width).saturating_add( self.status @@ -133,7 +143,7 @@ impl BottomPane { width: area.width, height: area.height - top_margin - bottom_margin, }; - match self.active_view.as_ref() { + match self.active_view() { Some(_) => [Rect::ZERO, area], None => { let status_height = self @@ -151,7 +161,7 @@ impl BottomPane { // In these states the textarea is not interactable, so we should not // show its caret. let [_, content] = self.layout(area); - if let Some(view) = self.active_view.as_ref() { + if let Some(view) = self.active_view() { view.cursor_pos(content) } else { self.composer.cursor_pos(content) @@ -160,12 +170,20 @@ impl BottomPane { /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { - if let Some(mut view) = self.active_view.take() { - view.handle_key_event(self, key_event); - if !view.is_complete() { - self.active_view = Some(view); - } else { + // If a modal/view is active, handle it here; otherwise forward to composer. + if let Some(view) = self.view_stack.last_mut() { + if key_event.code == KeyCode::Esc + && matches!(view.on_ctrl_c(), CancellationEvent::Handled) + && view.is_complete() + { + self.view_stack.pop(); self.on_active_view_complete(); + } else { + view.handle_key_event(key_event); + if view.is_complete() { + self.view_stack.clear(); + self.on_active_view_complete(); + } } self.request_redraw(); InputResult::None @@ -195,43 +213,31 @@ impl BottomPane { /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a /// chance to consume the event (e.g. to dismiss itself). pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { - let mut view = match self.active_view.take() { - Some(view) => view, - None => { - return if self.composer_is_empty() { - CancellationEvent::NotHandled - } else { - self.set_composer_text(String::new()); - self.show_ctrl_c_quit_hint(); - CancellationEvent::Handled - }; - } - }; - - let event = view.on_ctrl_c(self); - match event { - CancellationEvent::Handled => { - if !view.is_complete() { - self.active_view = Some(view); - } else { + if let Some(view) = self.view_stack.last_mut() { + let event = view.on_ctrl_c(); + if matches!(event, CancellationEvent::Handled) { + if view.is_complete() { + self.view_stack.pop(); self.on_active_view_complete(); } self.show_ctrl_c_quit_hint(); } - CancellationEvent::NotHandled => { - self.active_view = Some(view); - } + event + } else if self.composer_is_empty() { + CancellationEvent::NotHandled + } else { + self.view_stack.pop(); + self.set_composer_text(String::new()); + self.show_ctrl_c_quit_hint(); + CancellationEvent::Handled } - event } pub fn handle_paste(&mut self, pasted: String) { - if let Some(mut view) = self.active_view.take() { - let needs_redraw = view.handle_paste(self, pasted); + if let Some(view) = self.view_stack.last_mut() { + let needs_redraw = view.handle_paste(pasted); if view.is_complete() { self.on_active_view_complete(); - } else { - self.active_view = Some(view); } if needs_redraw { self.request_redraw(); @@ -332,7 +338,7 @@ impl BottomPane { /// Show a generic list selection view with the provided items. pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); - self.active_view = Some(Box::new(view)); + self.push_view(Box::new(view)); } /// Update the queued messages shown under the status header. @@ -362,7 +368,7 @@ impl BottomPane { /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - !self.is_task_running && self.active_view.is_none() && !self.composer.popup_active() + !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } /// Update the *context-window remaining* indicator in the composer. This @@ -373,13 +379,12 @@ impl BottomPane { } pub(crate) fn show_view(&mut self, view: Box) { - self.active_view = Some(view); - self.request_redraw(); + self.push_view(view); } /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest) { - let request = if let Some(view) = self.active_view.as_mut() { + let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_approval_request(request) { Some(request) => request, None => { @@ -394,8 +399,7 @@ impl BottomPane { // Otherwise create a new approval modal overlay. let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); self.pause_status_timer_for_modal(); - self.active_view = Some(Box::new(modal)); - self.request_redraw() + self.push_view(Box::new(modal)); } fn on_active_view_complete(&mut self) { @@ -464,7 +468,7 @@ impl BottomPane { height: u32, format_label: &str, ) { - if self.active_view.is_none() { + if self.view_stack.is_empty() { self.composer .attach_image(path, width, height, format_label); self.request_redraw(); @@ -481,7 +485,7 @@ impl WidgetRef for &BottomPane { let [status_area, content] = self.layout(area); // When a modal view is active, it owns the whole content area. - if let Some(view) = &self.active_view { + if let Some(view) = self.active_view() { view.render(content, buf); } else { // No active modal: @@ -591,7 +595,7 @@ mod tests { // After denial, since the task is still running, the status indicator should be // visible above the composer. The modal should be gone. assert!( - pane.active_view.is_none(), + pane.view_stack.is_empty(), "no active modal view after denial" ); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bbcc3df273..480380b207 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1645,7 +1645,6 @@ impl ChatWidget { title: "Select a review preset".into(), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), items, - on_escape: None, ..Default::default() }); } @@ -1684,7 +1683,6 @@ impl ChatWidget { items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), - on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), ..Default::default() }); } @@ -1726,7 +1724,6 @@ impl ChatWidget { items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), - on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), ..Default::default() }); } @@ -1737,8 +1734,6 @@ impl ChatWidget { "Custom review instructions".to_string(), "Type instructions and press Enter".to_string(), None, - self.app_event_tx.clone(), - Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))), Box::new(move |prompt: String| { let trimmed = prompt.trim().to_string(); if trimmed.is_empty() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 028d909b00..284e20f401 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -851,7 +851,7 @@ fn interrupt_exec_marks_failed_snapshot() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[test] fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Open the Review presets parent popup. chat.open_review_popup(); @@ -868,13 +868,6 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { // Esc once: child view closes, parent (review presets) remains. chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - // Process emitted app events to reopen the parent review popup. - while let Ok(ev) = rx.try_recv() { - if let AppEvent::OpenReviewPopup = ev { - chat.open_review_popup(); - break; - } - } let header = render_bottom_first_row(&chat, 60); assert!( header.contains("Select a review preset"), @@ -893,7 +886,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test(flavor = "current_thread")] async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Open the Review presets parent popup. chat.open_review_popup(); @@ -911,13 +904,6 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { // Esc once: child view closes, parent remains. chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - // Process emitted app events to reopen the parent review popup. - while let Ok(ev) = rx.try_recv() { - if let AppEvent::OpenReviewPopup = ev { - chat.open_review_popup(); - break; - } - } let header = render_bottom_first_row(&chat, 60); assert!( header.contains("Select a review preset"), From c75920a071fb66733f177e7511a78ed00d466cf3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Sep 2025 11:52:45 -0700 Subject: [PATCH 35/42] Change limits warning copy (#4059) --- codex-rs/tui/src/chatwidget.rs | 4 ++-- codex-rs/tui/src/chatwidget/tests.rs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 480380b207..f5b95e5323 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -128,7 +128,7 @@ impl RateLimitWarningState { { let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]; warnings.push(format!( - "Weekly usage exceeded {threshold:.0}% of the limit. Check /status to review usage." + "Heads up, you've used over {threshold:.0}% of your weekly limit. Run /status for a breakdown." )); self.weekly_index += 1; } @@ -138,7 +138,7 @@ impl RateLimitWarningState { { let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]; warnings.push(format!( - "Hourly usage exceeded {threshold:.0}% of the limit. Check /status to review usage." + "Heads up, you've used over {threshold:.0}% of your 5h limit. Run /status for a breakdown." )); self.hourly_index += 1; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 284e20f401..2837cedc25 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -397,26 +397,26 @@ fn rate_limit_warnings_emit_thresholds() { assert!( warnings .iter() - .any(|w| w.contains("Hourly usage exceeded 50%")), - "expected hourly 50% warning" + .any(|w| w.contains("Heads up, you've used over 50% of your 5h limit.")), + "expected hourly 50% warning (new copy)" ); assert!( warnings .iter() - .any(|w| w.contains("Weekly usage exceeded 50%")), - "expected weekly 50% warning" + .any(|w| w.contains("Heads up, you've used over 50% of your weekly limit.")), + "expected weekly 50% warning (new copy)" ); assert!( warnings .iter() - .any(|w| w.contains("Hourly usage exceeded 90%")), - "expected hourly 90% warning" + .any(|w| w.contains("Heads up, you've used over 90% of your 5h limit.")), + "expected hourly 90% warning (new copy)" ); assert!( warnings .iter() - .any(|w| w.contains("Weekly usage exceeded 90%")), - "expected weekly 90% warning" + .any(|w| w.contains("Heads up, you've used over 90% of your weekly limit.")), + "expected weekly 90% warning (new copy)" ); } From be366a31ab973f22eaf7c9499d21b5d198de16ee Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Sep 2025 20:30:16 +0100 Subject: [PATCH 36/42] chore: clippy on redundant closure (#4058) Add redundant closure clippy rules and let Codex fix it by minimising FQP --- codex-rs/Cargo.toml | 2 ++ codex-rs/apply-patch/src/lib.rs | 18 ++++++------- codex-rs/apply-patch/src/seek_sequence.rs | 3 ++- codex-rs/arg0/src/lib.rs | 2 +- codex-rs/core/src/bash.rs | 3 ++- codex-rs/core/src/chat_completions.rs | 2 +- codex-rs/core/src/client.rs | 3 ++- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config_edit.rs | 2 +- codex-rs/core/src/custom_prompts.rs | 2 +- codex-rs/core/src/git_info.rs | 4 +-- codex-rs/core/src/is_safe_command.rs | 3 ++- codex-rs/core/src/openai_tools.rs | 5 +--- codex-rs/core/src/parse_command.rs | 27 ++++++++++--------- codex-rs/core/src/shell.rs | 14 +++++----- codex-rs/core/tests/suite/exec.rs | 3 ++- .../src/event_processor_with_human_output.rs | 2 +- codex-rs/exec/tests/suite/resume.rs | 3 ++- codex-rs/execpolicy/src/execv_checker.rs | 3 ++- codex-rs/execpolicy/src/main.rs | 7 ++--- codex-rs/execpolicy/src/program.rs | 2 +- .../linux-sandbox/tests/suite/landlock.rs | 4 +-- codex-rs/login/src/server.rs | 5 ++-- codex-rs/mcp-server/src/exec_approval.rs | 2 +- codex-rs/mcp-server/tests/suite/codex_tool.rs | 2 +- codex-rs/ollama/src/client.rs | 2 +- codex-rs/ollama/src/parser.rs | 4 +-- codex-rs/protocol-ts/src/lib.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 +++--- codex-rs/tui/src/bottom_pane/command_popup.rs | 3 ++- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/bottom_pane/paste_burst.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 7 +++-- codex-rs/tui/src/clipboard_paste.rs | 2 +- codex-rs/tui/src/diff_render.rs | 3 ++- codex-rs/tui/src/exec_command.rs | 2 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/markdown_render.rs | 8 ++---- codex-rs/tui/src/new_model_popup.rs | 2 +- codex-rs/tui/src/onboarding/welcome.rs | 2 +- codex-rs/tui/src/pager_overlay.rs | 2 +- codex-rs/tui/src/wrapping.rs | 3 ++- 43 files changed, 97 insertions(+), 86 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1ec4191e7a..7e0d562074 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -174,6 +174,8 @@ needless_option_as_deref = "deny" needless_question_mark = "deny" needless_update = "deny" redundant_clone = "deny" +redundant_closure = "deny" +redundant_closure_for_method_calls = "deny" redundant_static_lifetimes = "deny" trivially_copy_pass_by_ref = "deny" uninlined_format_args = "deny" diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 5bda31c4c2..189c1a07d0 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -648,21 +648,18 @@ fn derive_new_contents_from_chunks( } }; - let mut original_lines: Vec = original_contents - .split('\n') - .map(|s| s.to_string()) - .collect(); + let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); // Drop the trailing empty element that results from the final newline so // that line counts match the behaviour of standard `diff`. - if original_lines.last().is_some_and(|s| s.is_empty()) { + if original_lines.last().is_some_and(String::is_empty) { original_lines.pop(); } let replacements = compute_replacements(&original_lines, path, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; - if !new_lines.last().is_some_and(|s| s.is_empty()) { + if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } let new_contents = new_lines.join("\n"); @@ -706,7 +703,7 @@ fn compute_replacements( if chunk.old_lines.is_empty() { // Pure addition (no old lines). We'll add them at the end or just // before the final empty line if one exists. - let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) { + let insertion_idx = if original_lines.last().is_some_and(String::is_empty) { original_lines.len() - 1 } else { original_lines.len() @@ -732,11 +729,11 @@ fn compute_replacements( let mut new_slice: &[String] = &chunk.new_lines; - if found.is_none() && pattern.last().is_some_and(|s| s.is_empty()) { + if found.is_none() && pattern.last().is_some_and(String::is_empty) { // Retry without the trailing empty line which represents the final // newline in the file. pattern = &pattern[..pattern.len() - 1]; - if new_slice.last().is_some_and(|s| s.is_empty()) { + if new_slice.last().is_some_and(String::is_empty) { new_slice = &new_slice[..new_slice.len() - 1]; } @@ -848,6 +845,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use std::fs; + use std::string::ToString; use tempfile::tempdir; /// Helper to construct a patch with the given body. @@ -856,7 +854,7 @@ mod tests { } fn strs_to_strings(strs: &[&str]) -> Vec { - strs.iter().map(|s| s.to_string()).collect() + strs.iter().map(ToString::to_string).collect() } // Test helpers to reduce repetition when building bash -lc heredoc scripts diff --git a/codex-rs/apply-patch/src/seek_sequence.rs b/codex-rs/apply-patch/src/seek_sequence.rs index 0144580f9b..b005b08c75 100644 --- a/codex-rs/apply-patch/src/seek_sequence.rs +++ b/codex-rs/apply-patch/src/seek_sequence.rs @@ -112,9 +112,10 @@ pub(crate) fn seek_sequence( #[cfg(test)] mod tests { use super::seek_sequence; + use std::string::ToString; fn to_vec(strings: &[&str]) -> Vec { - strings.iter().map(|s| s.to_string()).collect() + strings.iter().map(ToString::to_string).collect() } #[test] diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d5df68e554..e70ff2df64 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -54,7 +54,7 @@ where let argv1 = args.next().unwrap_or_default(); if argv1 == CODEX_APPLY_PATCH_ARG1 { - let patch_arg = args.next().and_then(|s| s.to_str().map(|s| s.to_owned())); + let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned)); let exit_code = match patch_arg { Some(patch_arg) => { let mut stdout = std::io::stdout(); diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 4bed3a9c16..f25b4f7f67 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -1,3 +1,4 @@ +use tree_sitter::Node; use tree_sitter::Parser; use tree_sitter::Tree; use tree_sitter_bash::LANGUAGE as BASH; @@ -74,7 +75,7 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option( 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()) + .map(str::to_string) .filter(|s| !s.is_empty()); if maybe_text.is_none() && reasoning_val.is_object() { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 57848d388b..d49bb6145b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -4,6 +4,7 @@ use std::sync::OnceLock; use std::time::Duration; use crate::AuthManager; +use crate::auth::CodexAuth; use bytes::Bytes; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::ConversationId; @@ -337,7 +338,7 @@ impl ModelClient { // token. let plan_type = error .plan_type - .or_else(|| auth.as_ref().and_then(|a| a.get_plan_type())); + .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); let resets_in_seconds = error.resets_in_seconds; return Err(CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dfea891921..08b28bdef5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1747,7 +1747,7 @@ async fn run_task( .unwrap_or(i64::MAX); let total_usage_tokens = total_token_usage .as_ref() - .map(|usage| usage.tokens_in_context_window()); + .map(TokenUsage::tokens_in_context_window); let token_limit_reached = total_usage_tokens .map(|tokens| (tokens as i64) >= limit) .unwrap_or(false); diff --git a/codex-rs/core/src/config_edit.rs b/codex-rs/core/src/config_edit.rs index 4b3a5cbdb0..7b1d75d08d 100644 --- a/codex-rs/core/src/config_edit.rs +++ b/codex-rs/core/src/config_edit.rs @@ -136,7 +136,7 @@ async fn persist_overrides_with_behavior( } else { doc.get("profile") .and_then(|i| i.as_str()) - .map(|s| s.to_string()) + .map(str::to_string) }; let mut mutated = false; diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index 4974e7c52d..357abef55b 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -52,7 +52,7 @@ pub async fn discover_prompts_in_excluding( let Some(name) = path .file_stem() .and_then(|s| s.to_str()) - .map(|s| s.to_string()) + .map(str::to_string) else { continue; }; diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 832b28f114..5c16b72da6 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -200,7 +200,7 @@ async fn get_git_remotes(cwd: &Path) -> Option> { let mut remotes: Vec = String::from_utf8(output.stdout) .ok()? .lines() - .map(|s| s.to_string()) + .map(str::to_string) .collect(); if let Some(pos) = remotes.iter().position(|r| r == "origin") { let origin = remotes.remove(pos); @@ -477,7 +477,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { let untracked: Vec = String::from_utf8(untracked_output.stdout) .ok()? .lines() - .map(|s| s.to_string()) + .map(str::to_string) .filter(|s| !s.is_empty()) .collect(); diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs index f54e247fd8..12eb36e0a5 100644 --- a/codex-rs/core/src/is_safe_command.rs +++ b/codex-rs/core/src/is_safe_command.rs @@ -160,9 +160,10 @@ fn is_valid_sed_n_arg(arg: Option<&str>) -> bool { #[cfg(test)] mod tests { use super::*; + use std::string::ToString; fn vec_str(args: &[&str]) -> Vec { - args.iter().map(|s| s.to_string()).collect() + args.iter().map(ToString::to_string).collect() } #[test] diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 05b71ce6ea..48dca79671 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -400,10 +400,7 @@ fn sanitize_json_schema(value: &mut JsonValue) { } // Normalize/ensure type - let mut ty = map - .get("type") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let mut ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string); // If type is an array (union), pick first supported; else leave to inference if ty.is_none() diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 1d2948a406..3c89b61c91 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -40,7 +40,7 @@ impl From for codex_protocol::parse_command::ParsedCommand { } fn shlex_join(tokens: &[String]) -> String { - shlex_try_join(tokens.iter().map(|s| s.as_str())) + shlex_try_join(tokens.iter().map(String::as_str)) .unwrap_or_else(|_| "".to_string()) } @@ -72,13 +72,14 @@ pub fn parse_command(command: &[String]) -> Vec { /// Tests are at the top to encourage using TDD + Codex to fix the implementation. mod tests { use super::*; + use std::string::ToString; fn shlex_split_safe(s: &str) -> Vec { - shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(|s| s.to_string()).collect()) + shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(ToString::to_string).collect()) } fn vec_str(args: &[&str]) -> Vec { - args.iter().map(|s| s.to_string()).collect() + args.iter().map(ToString::to_string).collect() } fn assert_parsed(args: &[String], expected: Vec) { @@ -894,7 +895,7 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { // echo ... && ...rest => ...rest if let ParsedCommand::Unknown { cmd } = &commands[0] - && shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("echo")) + && shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("echo")) { return Some(commands[1..].to_vec()); } @@ -902,7 +903,7 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { // 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")) + shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("cd")) } _ => false, }) && commands.len() > idx + 1 @@ -1035,7 +1036,7 @@ fn short_display_path(path: &str) -> String { }); parts .next() - .map(|s| s.to_string()) + .map(str::to_string) .unwrap_or_else(|| trimmed.to_string()) } @@ -1190,8 +1191,8 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { if had_connectors { let has_pipe = script_tokens.iter().any(|t| t == "|"); let has_sed_n = script_tokens.windows(2).any(|w| { - w.first().map(|s| s.as_str()) == Some("sed") - && w.get(1).map(|s| s.as_str()) == Some("-n") + w.first().map(String::as_str) == Some("sed") + && w.get(1).map(String::as_str) == Some("-n") }); if has_pipe && has_sed_n { ParsedCommand::Read { @@ -1271,7 +1272,7 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { // Keep `sed -n file` (treated as a file read elsewhere); // otherwise consider it a formatting helper in a pipeline. tokens.len() < 4 - || !(tokens[1] == "-n" && is_valid_sed_n_arg(tokens.get(2).map(|s| s.as_str()))) + || !(tokens[1] == "-n" && is_valid_sed_n_arg(tokens.get(2).map(String::as_str))) } _ => false, } @@ -1318,7 +1319,7 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { (None, non_flags.first().map(|s| short_display_path(s))) } else { ( - non_flags.first().cloned().map(|s| s.to_string()), + non_flags.first().cloned().map(String::from), non_flags.get(1).map(|s| short_display_path(s)), ) }; @@ -1353,7 +1354,7 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { .collect(); // Do not shorten the query: grep patterns may legitimately contain slashes // and should be preserved verbatim. Only paths should be shortened. - let query = non_flags.first().cloned().map(|s| s.to_string()); + let query = non_flags.first().cloned().map(String::from); let path = non_flags.get(1).map(|s| short_display_path(s)); ParsedCommand::Search { cmd: shlex_join(main_cmd), @@ -1363,7 +1364,7 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { } Some((head, tail)) if head == "cat" => { // Support both `cat ` and `cat -- ` forms. - let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") { + let effective_tail: &[String] = if tail.first().map(String::as_str) == Some("--") { &tail[1..] } else { tail @@ -1479,7 +1480,7 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { if head == "sed" && tail.len() >= 3 && tail[0] == "-n" - && is_valid_sed_n_arg(tail.get(1).map(|s| s.as_str())) => + && is_valid_sed_n_arg(tail.get(1).map(String::as_str)) => { if let Some(path) = tail.get(2) { let name = short_display_path(path); diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 3bfef7e1e0..ac93d8b12e 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -73,7 +73,7 @@ impl Shell { return Some(command); } - let joined = shlex::try_join(command.iter().map(|s| s.as_str())).ok(); + let joined = shlex::try_join(command.iter().map(String::as_str)).ok(); return joined.map(|arg| { vec![ ps.exe.clone(), @@ -111,7 +111,7 @@ fn format_shell_invocation_with_rc( rc_path: &str, ) -> Option> { let joined = strip_bash_lc(command) - .or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?; + .or_else(|| shlex::try_join(command.iter().map(String::as_str)).ok())?; let rc_command = if std::path::Path::new(rc_path).exists() { format!("source {rc_path} && ({joined})") @@ -224,6 +224,7 @@ pub async fn default_user_shell() -> Shell { mod tests { use super::*; use std::process::Command; + use std::string::ToString; #[tokio::test] async fn test_current_shell_detects_zsh() { @@ -327,7 +328,7 @@ mod tests { }); let actual_cmd = shell - .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + .format_default_shell_invocation(input.iter().map(ToString::to_string).collect()); let expected_cmd = expected_cmd .iter() .map(|s| s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap())) @@ -371,6 +372,7 @@ mod tests { #[cfg(target_os = "macos")] mod macos_tests { use super::*; + use std::string::ToString; #[tokio::test] async fn test_run_with_profile_escaping_and_execution() { @@ -434,7 +436,7 @@ mod macos_tests { }); let actual_cmd = shell - .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + .format_default_shell_invocation(input.iter().map(ToString::to_string).collect()); let expected_cmd = expected_cmd .iter() .map(|s| s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())) @@ -559,10 +561,10 @@ mod tests_windows { for (shell, input, expected_cmd) in cases { let actual_cmd = shell - .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + .format_default_shell_invocation(input.iter().map(|s| (*s).to_string()).collect()); assert_eq!( actual_cmd, - Some(expected_cmd.iter().map(|s| s.to_string()).collect()) + Some(expected_cmd.iter().map(|s| (*s).to_string()).collect()) ); } } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 280917b530..2d1e99b0b7 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -1,6 +1,7 @@ #![cfg(target_os = "macos")] use std::collections::HashMap; +use std::string::ToString; use codex_core::exec::ExecParams; use codex_core::exec::ExecToolCallOutput; @@ -29,7 +30,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result String { - try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) + try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } fn format_file_change(change: &FileChange) -> &'static str { diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 5868ed8eb2..4c4d343ee3 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -3,6 +3,7 @@ use anyhow::Context; use assert_cmd::prelude::*; use serde_json::Value; use std::process::Command; +use std::string::ToString; use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; @@ -45,7 +46,7 @@ fn find_session_file_containing_marker( && payload.get("type").and_then(|t| t.as_str()) == Some("message") && payload .get("content") - .map(|c| c.to_string()) + .map(ToString::to_string) .unwrap_or_default() .contains(marker) { diff --git a/codex-rs/execpolicy/src/execv_checker.rs b/codex-rs/execpolicy/src/execv_checker.rs index ae61d54cf8..1c03f591b1 100644 --- a/codex-rs/execpolicy/src/execv_checker.rs +++ b/codex-rs/execpolicy/src/execv_checker.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; @@ -108,7 +109,7 @@ fn ensure_absolute_path(path: &str, cwd: &Option) -> Result { file.absolutize() }; result - .map(|path| path.into_owned()) + .map(Cow::into_owned) .map_err(|error| CannotCanonicalizePath { file: path.to_string(), error: error.kind(), diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs index 0a4b1764ca..68a72c04ff 100644 --- a/codex-rs/execpolicy/src/main.rs +++ b/codex-rs/execpolicy/src/main.rs @@ -10,6 +10,7 @@ use codex_execpolicy::get_default_policy; use serde::Deserialize; use serde::Serialize; use serde::de; +use starlark::Error as StarlarkError; use std::path::PathBuf; use std::str::FromStr; @@ -71,13 +72,13 @@ fn main() -> Result<()> { } None => get_default_policy(), }; - let policy = policy.map_err(|err| err.into_anyhow())?; + let policy = policy.map_err(StarlarkError::into_anyhow)?; let exec = match args.command { Command::Check { command } => match command.split_first() { Some((first, rest)) => ExecArg { program: first.to_string(), - args: rest.iter().map(|s| s.to_string()).collect(), + args: rest.to_vec(), }, None => { eprintln!("no command provided"); @@ -161,6 +162,6 @@ impl FromStr for ExecArg { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| e.into()) + serde_json::from_str(s).map_err(Into::into) } } diff --git a/codex-rs/execpolicy/src/program.rs b/codex-rs/execpolicy/src/program.rs index fbe0a104af..d0cec3717b 100644 --- a/codex-rs/execpolicy/src/program.rs +++ b/codex-rs/execpolicy/src/program.rs @@ -169,7 +169,7 @@ impl ProgramSpec { let mut options = self .required_options .difference(&matched_opt_names) - .map(|s| s.to_string()) + .map(String::from) .collect::>(); options.sort(); return Err(Error::MissingRequiredOptions { diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index ea4930fada..6af59ef0fa 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -38,7 +38,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); let params = ExecParams { - command: cmd.iter().map(|elm| elm.to_string()).collect(), + command: cmd.iter().copied().map(str::to_owned).collect(), cwd, timeout_ms: Some(timeout_ms), env: create_env_from_core_vars(), @@ -138,7 +138,7 @@ async fn assert_network_blocked(cmd: &[&str]) { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); let params = ExecParams { - command: cmd.iter().map(|s| s.to_string()).collect(), + command: cmd.iter().copied().map(str::to_owned).collect(), cwd, // Give the tool a generous 2-second timeout so even slow DNS timeouts // do not stall the suite. diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 7255e4682b..a1e5e6c3b0 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -20,6 +20,7 @@ use codex_core::default_client::ORIGINATOR; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; use rand::RngCore; +use serde_json::Value as JsonValue; use tiny_http::Header; use tiny_http::Request; use tiny_http::Response; @@ -496,11 +497,11 @@ fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &s .unwrap_or(""); let completed_onboarding = token_claims .get("completed_platform_onboarding") - .and_then(|v| v.as_bool()) + .and_then(JsonValue::as_bool) .unwrap_or(false); let is_org_owner = token_claims .get("is_org_owner") - .and_then(|v| v.as_bool()) + .and_then(JsonValue::as_bool) .unwrap_or(false); let needs_setup = (!completed_onboarding) && is_org_owner; let plan_type = access_claims diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 1985cc79e6..119481cda9 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -58,7 +58,7 @@ pub(crate) async fn handle_exec_approval_request( call_id: String, ) { let escaped_command = - shlex::try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")); + shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); let message = format!( "Allow Codex to run `{escaped_command}` in `{cwd}`?", cwd = cwd.to_string_lossy() diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 78f758d69b..a111539d2f 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -173,7 +173,7 @@ fn create_expected_elicitation_request( ) -> anyhow::Result { let expected_message = format!( "Allow Codex to run `{}` in `{}`?", - shlex::try_join(command.iter().map(|s| s.as_ref()))?, + shlex::try_join(command.iter().map(std::convert::AsRef::as_ref))?, workdir.to_string_lossy() ); Ok(JSONRPCRequest { diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 45e0486156..04b7e9dea2 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -117,7 +117,7 @@ impl OllamaClient { .map(|arr| { arr.iter() .filter_map(|v| v.get("name").and_then(|n| n.as_str())) - .map(|s| s.to_string()) + .map(str::to_string) .collect::>() }) .unwrap_or_default(); diff --git a/codex-rs/ollama/src/parser.rs b/codex-rs/ollama/src/parser.rs index b3ed2ca8c3..c39df6680f 100644 --- a/codex-rs/ollama/src/parser.rs +++ b/codex-rs/ollama/src/parser.rs @@ -16,8 +16,8 @@ pub(crate) fn pull_events_from_value(value: &JsonValue) -> Vec { .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); - let total = value.get("total").and_then(|t| t.as_u64()); - let completed = value.get("completed").and_then(|t| t.as_u64()); + let total = value.get("total").and_then(JsonValue::as_u64); + let completed = value.get("completed").and_then(JsonValue::as_u64); if total.is_some() || completed.is_some() { events.push(PullEvent::ChunkProgress { digest, diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 967d9b1fcc..3aec3d892f 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -134,7 +134,7 @@ fn generate_index_ts(out_dir: &Path) -> Result { } let mut content = - String::with_capacity(HEADER.len() + entries.iter().map(|s| s.len()).sum::()); + String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); content.push_str(HEADER); for line in &entries { content.push_str(line); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d15265c50a..65203b5193 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -421,7 +421,7 @@ impl ChatComposer { // Capture any needed data from popup before clearing it. let prompt_content = match sel { CommandItem::UserPrompt(idx) => { - popup.prompt_content(idx).map(|s| s.to_string()) + popup.prompt_content(idx).map(str::to_string) } _ => None, }; @@ -550,7 +550,7 @@ impl ChatComposer { let format_label = match Path::new(&sel_path) .extension() .and_then(|e| e.to_str()) - .map(|s| s.to_ascii_lowercase()) + .map(str::to_ascii_lowercase) { Some(ext) if ext == "png" => "PNG", Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", @@ -617,7 +617,7 @@ impl ChatComposer { text[safe_cursor..] .chars() .next() - .map(|c| c.is_whitespace()) + .map(char::is_whitespace) .unwrap_or(false) } else { false @@ -645,7 +645,7 @@ impl ChatComposer { let ws_len_right: usize = after_cursor .chars() .take_while(|c| c.is_whitespace()) - .map(|c| c.len_utf8()) + .map(char::len_utf8) .sum(); let start_right = safe_cursor + ws_len_right; let end_right_rel = text[start_right..] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index a492a2e17e..a0933f4ae6 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -218,6 +218,7 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; + use std::string::ToString; #[test] fn filter_includes_init_when_typing_prefix() { @@ -287,7 +288,7 @@ mod tests { let mut prompt_names: Vec = items .into_iter() .filter_map(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt_name(i).map(|s| s.to_string()), + CommandItem::UserPrompt(i) => popup.prompt_name(i).map(ToString::to_string), _ => None, }) .collect(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 523043e6f3..fc9f06c39d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -103,7 +103,7 @@ impl BottomPane { } fn active_view(&self) -> Option<&dyn BottomPaneView> { - self.view_stack.last().map(|view| view.as_ref()) + self.view_stack.last().map(std::convert::AsRef::as_ref) } fn push_view(&mut self, view: Box) { diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index d507dcae37..559744a462 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -183,7 +183,7 @@ impl PasteBurst { let start_byte = retro_start_index(before, retro_chars); let grabbed = before[start_byte..].to_string(); let looks_pastey = - grabbed.chars().any(|c| c.is_whitespace()) || grabbed.chars().count() >= 16; + grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16; if looks_pastey { // Note: caller is responsible for removing this slice from UI text. self.begin_with_retro_grabbed(grabbed.clone(), now); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f5b95e5323..32375a869a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -616,7 +616,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); // Emit the proposed command into history (like proposed patches) self.add_to_history(history_cell::new_proposed_command(&ev.command)); - let command = shlex::try_join(ev.command.iter().map(|s| s.as_str())) + let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); self.notify(Notification::ExecApprovalRequested { command }); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2837cedc25..b983f6f553 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1044,7 +1044,10 @@ async fn binary_size_transcript_snapshot() { call_id: e.call_id.clone(), command: e.command, cwd: e.cwd, - parsed_cmd: parsed_cmd.into_iter().map(|c| c.into()).collect(), + parsed_cmd: parsed_cmd + .into_iter() + .map(std::convert::Into::into) + .collect(), }), } } @@ -1121,7 +1124,7 @@ async fn binary_size_transcript_snapshot() { // Trim trailing spaces to match plain text fixture lines.push(s.trim_end().to_string()); } - while lines.last().is_some_and(|l| l.is_empty()) { + while lines.last().is_some_and(std::string::String::is_empty) { lines.pop(); } // Consider content only after the last session banner marker. Skip the transient diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 82a140453a..4945eea350 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -198,7 +198,7 @@ pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path .extension() .and_then(|e| e.to_str()) - .map(|s| s.to_ascii_lowercase()) + .map(str::to_ascii_lowercase) .as_deref() { Some("png") => EncodedImageFormat::Png, diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 2787f76607..1e0c222bbd 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -1,3 +1,4 @@ +use diffy::Hunk; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; @@ -283,7 +284,7 @@ fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { patch .hunks() .iter() - .flat_map(|h| h.lines()) + .flat_map(Hunk::lines) .fold((0, 0), |(a, d), l| match l { diffy::Line::Insert(_) => (a + 1, d), diffy::Line::Delete(_) => (a, d + 1), diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 93c937e0d1..5c9a0d039a 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -5,7 +5,7 @@ use dirs::home_dir; use shlex::try_join; pub(crate) fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) + try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d4697218c5..cbb5ad6b48 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -388,7 +388,7 @@ impl ExecCell { vec![( "Read", itertools::Itertools::intersperse( - names.into_iter().map(|n| n.into()), + names.into_iter().map(Into::into), ", ".dim(), ) .collect(), diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index f20bfd75c0..cac40cffde 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -50,11 +50,7 @@ pub(crate) fn render_markdown_text_with_citations( let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); - let mut w = Writer::new( - parser, - scheme.map(|s| s.to_string()), - Some(cwd.to_path_buf()), - ); + let mut w = Writer::new(parser, scheme.map(str::to_string), Some(cwd.to_path_buf())); w.run(); w.text } @@ -329,7 +325,7 @@ where let is_ordered = self .list_indices .last() - .map(|index| index.is_some()) + .map(Option::is_some) .unwrap_or(false); let width = depth * 4 - 3; let marker = if let Some(last_index) = self.list_indices.last_mut() { diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs index 2106e46c67..e2389aad59 100644 --- a/codex-rs/tui/src/new_model_popup.rs +++ b/codex-rs/tui/src/new_model_popup.rs @@ -98,7 +98,7 @@ impl WidgetRef for &ModelUpgradePopup { let mut lines: Vec = Vec::new(); if show_animation { let frame = self.animation.current_frame(); - lines.extend(frame.lines().map(|l| l.into())); + lines.extend(frame.lines().map(Into::into)); // Spacer between animation and text content. lines.push("".into()); } diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index c62b6402d8..645c86ba43 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -62,7 +62,7 @@ impl WidgetRef for &WelcomeWidget { let frame = self.animation.current_frame(); // let frame_line_count = frame.lines().count(); // lines.reserve(frame_line_count + 2); - lines.extend(frame.lines().map(|l| l.into())); + lines.extend(frame.lines().map(Into::into)); lines.push("".into()); } lines.push(Line::from(vec![ diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index e5524b252b..271674b32f 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -382,7 +382,7 @@ impl TranscriptOverlay { let cell_lines = if Some(idx) == highlight_cell { cell.transcript_lines() .into_iter() - .map(|l| l.reversed()) + .map(Stylize::reversed) .collect() } else { cell.transcript_lines() diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 1dc4e2e553..bd3c451bf9 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -329,6 +329,7 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::style::Color; use ratatui::style::Stylize; + use std::string::ToString; fn concat_line(line: &Line) -> String { line.spans @@ -543,7 +544,7 @@ mod tests { let lines = [line]; // Force small width to exercise wrapping at spaces. let wrapped = word_wrap_lines_borrowed(&lines, 40); - let joined: String = wrapped.iter().map(|l| l.to_string()).join("\n"); + let joined: String = wrapped.iter().map(ToString::to_string).join("\n"); assert_eq!( joined, r#"Years passed, and Willowmere thrived From 8bc73a2bfd2a5fcaaff586a23e6f5f9bf7a7f722 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Mon, 22 Sep 2025 12:34:08 -0700 Subject: [PATCH 37/42] Fix branch mode prompt for /review (#4061) Updates `/review` branch mode to review against a branch's upstream. --- codex-rs/tui/src/chatwidget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 32375a869a..e06aeb298b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1666,7 +1666,7 @@ impl ChatWidget { tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt: format!( - "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings." + "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{{upstream}}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings." ), user_facing_hint: format!("changes against '{branch}'"), }, From dd56750612778a63da31c172061fe69ed597dc78 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Sep 2025 14:06:20 -0700 Subject: [PATCH 38/42] Change headers and struct of rate limits (#4060) --- codex-rs/core/src/client.rs | 14 ++++++------ codex-rs/core/tests/suite/client.rs | 12 +++++------ codex-rs/protocol/src/protocol.rs | 12 +++++------ codex-rs/tui/src/chatwidget.rs | 33 +++++++++++++++++------------ codex-rs/tui/src/history_cell.rs | 2 +- 5 files changed, 39 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d49bb6145b..72ca770abd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -487,18 +487,18 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?; - let weekly_used_percent = parse_header_f64(headers, "x-codex-protection-used-percent")?; - let primary_to_weekly_ratio_percent = - parse_header_f64(headers, "x-codex-primary-over-protection-limit-percent")?; + let secondary_used_percent = parse_header_f64(headers, "x-codex-secondary-used-percent")?; + let primary_to_secondary_ratio_percent = + parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?; let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?; - let weekly_window_minutes = parse_header_u64(headers, "x-codex-protection-window-minutes")?; + let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?; Some(RateLimitSnapshotEvent { primary_used_percent, - weekly_used_percent, - primary_to_weekly_ratio_percent, + secondary_used_percent, + primary_to_secondary_ratio_percent, primary_window_minutes, - weekly_window_minutes, + secondary_window_minutes, }) } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 002a4b1f9d..bc6886746f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -761,10 +761,10 @@ async fn token_count_includes_rate_limits_snapshot() { let response = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") .insert_header("x-codex-primary-used-percent", "12.5") - .insert_header("x-codex-protection-used-percent", "40.0") - .insert_header("x-codex-primary-over-protection-limit-percent", "75.0") + .insert_header("x-codex-secondary-used-percent", "40.0") + .insert_header("x-codex-primary-over-secondary-limit-percent", "75.0") .insert_header("x-codex-primary-window-minutes", "10") - .insert_header("x-codex-protection-window-minutes", "60") + .insert_header("x-codex-secondary-window-minutes", "60") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -827,10 +827,10 @@ async fn token_count_includes_rate_limits_snapshot() { }, "rate_limits": { "primary_used_percent": 12.5, - "weekly_used_percent": 40.0, - "primary_to_weekly_ratio_percent": 75.0, + "secondary_used_percent": 40.0, + "primary_to_secondary_ratio_percent": 75.0, "primary_window_minutes": 10, - "weekly_window_minutes": 60 + "secondary_window_minutes": 60 } }) ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index edcdcbebf8..478fcd5f10 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -596,14 +596,14 @@ pub struct TokenCountEvent { pub struct RateLimitSnapshotEvent { /// Percentage (0-100) of the primary window that has been consumed. pub primary_used_percent: f64, - /// Percentage (0-100) of the protection window that has been consumed. - pub weekly_used_percent: f64, - /// Size of the primary window relative to weekly (0-100). - pub primary_to_weekly_ratio_percent: f64, + /// Percentage (0-100) of the secondary window that has been consumed. + pub secondary_used_percent: f64, + /// Size of the primary window relative to secondary (0-100). + pub primary_to_secondary_ratio_percent: f64, /// Rolling window duration for the primary limit, in minutes. pub primary_window_minutes: u64, - /// Rolling window duration for the weekly limit, in minutes. - pub weekly_window_minutes: u64, + /// Rolling window duration for the secondary limit, in minutes. + pub secondary_window_minutes: u64, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e06aeb298b..fc36809672 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -115,32 +115,36 @@ const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0]; #[derive(Default)] struct RateLimitWarningState { - weekly_index: usize, - hourly_index: usize, + secondary_index: usize, + primary_index: usize, } impl RateLimitWarningState { - fn take_warnings(&mut self, weekly_used_percent: f64, hourly_used_percent: f64) -> Vec { + fn take_warnings( + &mut self, + secondary_used_percent: f64, + primary_used_percent: f64, + ) -> Vec { let mut warnings = Vec::new(); - while self.weekly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && weekly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index] + while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] { - let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]; + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]; warnings.push(format!( "Heads up, you've used over {threshold:.0}% of your weekly limit. Run /status for a breakdown." )); - self.weekly_index += 1; + self.secondary_index += 1; } - while self.hourly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && hourly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index] + while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] { - let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]; + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]; warnings.push(format!( "Heads up, you've used over {threshold:.0}% of your 5h limit. Run /status for a breakdown." )); - self.hourly_index += 1; + self.primary_index += 1; } warnings @@ -339,9 +343,10 @@ impl ChatWidget { fn on_rate_limit_snapshot(&mut self, snapshot: Option) { if let Some(snapshot) = snapshot { - let warnings = self - .rate_limit_warnings - .take_warnings(snapshot.weekly_used_percent, snapshot.primary_used_percent); + let warnings = self.rate_limit_warnings.take_warnings( + snapshot.secondary_used_percent, + snapshot.primary_used_percent, + ); self.rate_limit_snapshot = Some(snapshot); if !warnings.is_empty() { for warning in warnings { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cbb5ad6b48..9eccf29ab0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1619,7 +1619,7 @@ fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotEvent>) -> Vec

  • { let rows = [ ("5h limit".to_string(), snapshot.primary_used_percent), - ("Weekly limit".to_string(), snapshot.weekly_used_percent), + ("Weekly limit".to_string(), snapshot.secondary_used_percent), ]; let label_width = rows .iter() From f54a49157b15729440a8dd5bd5e0e517b09f3a31 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:12:29 -0700 Subject: [PATCH 39/42] Fix pager overlay clear between pages (#3952) should fix characters sometimes hanging around while scrolling the transcript. --- codex-rs/tui/src/pager_overlay.rs | 108 ++++++++++++++++++ ...ript_overlay_apply_patch_scroll_vt100.snap | 16 +++ 2 files changed, 124 insertions(+) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 271674b32f..b92939a1a5 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -18,7 +18,9 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; pub(crate) enum Overlay { @@ -98,6 +100,7 @@ impl PagerView { } fn render(&mut self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); self.render_header(area, buf); let content_area = self.scroll_area(area); self.update_last_content_height(content_area.height); @@ -139,6 +142,7 @@ impl PagerView { // Removed unused render_content_page (replaced by render_content_page_prepared) fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) { + Clear.render(area, buf); Paragraph::new(page.to_vec()).render_ref(area, buf); let visible = page.len(); @@ -547,6 +551,17 @@ impl StaticOverlay { mod tests { use super::*; use insta::assert_snapshot; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; + + use crate::history_cell::CommandOutput; + use crate::history_cell::HistoryCell; + use crate::history_cell::PatchEventType; + use crate::history_cell::new_patch_event; + use codex_core::protocol::FileChange; + use codex_protocol::parse_command::ParsedCommand; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -610,6 +625,99 @@ mod tests { assert_snapshot!(term.backend()); } + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + out.push(' '); + } else { + out.push(symbol.chars().next().unwrap_or(' ')); + } + } + // Trim trailing spaces for stability. + while out.ends_with(' ') { + out.pop(); + } + out.push('\n'); + } + out + } + + #[test] + fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() { + let cwd = PathBuf::from("/repo"); + let mut cells: Vec> = Vec::new(); + + let mut approval_changes = HashMap::new(); + approval_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\nworld\n".to_string(), + }, + ); + let approval_cell: Arc = Arc::new(new_patch_event( + PatchEventType::ApprovalRequest, + approval_changes, + &cwd, + )); + cells.push(approval_cell); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\nworld\n".to_string(), + }, + ); + let apply_begin_cell: Arc = Arc::new(new_patch_event( + PatchEventType::ApplyBegin { + auto_approved: false, + }, + apply_changes, + &cwd, + )); + cells.push(apply_begin_cell); + + let apply_end_cell: Arc = + Arc::new(crate::history_cell::new_user_approval_decision(vec![ + "✓ Patch applied".green().bold().into(), + "src/foo.txt".dim().into(), + ])); + cells.push(apply_end_cell); + + let mut exec_cell = crate::history_cell::new_active_exec_command( + "exec-1".into(), + vec!["bash".into(), "-lc".into(), "ls".into()], + vec![ParsedCommand::Unknown { cmd: "ls".into() }], + ); + exec_cell.complete_call( + "exec-1", + CommandOutput { + exit_code: 0, + stdout: "src\nREADME.md\n".into(), + stderr: String::new(), + formatted_output: "src\nREADME.md\n".into(), + }, + Duration::from_millis(420), + ); + let exec_cell: Arc = Arc::new(exec_cell); + cells.push(exec_cell); + + let mut overlay = TranscriptOverlay::new(cells); + let area = Rect::new(0, 0, 80, 12); + let mut buf = Buffer::empty(area); + + overlay.render(area, &mut buf); + overlay.view.scroll_offset = 0; + overlay.view.wrap_cache = None; + overlay.render(area, &mut buf); + + let snapshot = buffer_to_text(&buf, area); + assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); + } + #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap new file mode 100644 index 0000000000..725f210c8d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/pager_overlay.rs +assertion_line: 721 +expression: snapshot +--- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / +• Proposed Change foo.txt (+2 -0) + 1 +hello + 2 +world + +• Change Approved foo.txt (+2 -0) + +✓ Patch applied +─────────────────────────────────────────────────────────────────────────── 0% ─ + ↑/↓ scroll PgUp/PgDn page Home/End jump + q quit Esc edit prev From 4e0550b99563f83121c6724aa1501cb288f20085 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:24:31 -0700 Subject: [PATCH 40/42] fix codex resume message at end of session (#3957) This was only being printed when running the codex-tui executable directly, not via the codex-cli wrapper. --- codex-rs/Cargo.lock | 3 +- codex-rs/cli/Cargo.toml | 4 +- codex-rs/cli/src/main.rs | 95 +++++++++++++++++++++++++++++++++++++--- codex-rs/tui/Cargo.toml | 1 - codex-rs/tui/src/main.rs | 13 ------ 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c618fb51ad..71681aa57f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -638,9 +638,11 @@ dependencies = [ "codex-protocol", "codex-protocol-ts", "codex-tui", + "owo-colors", "predicates", "pretty_assertions", "serde_json", + "supports-color", "tempfile", "tokio", "tracing", @@ -931,7 +933,6 @@ dependencies = [ "libc", "mcp-types", "once_cell", - "owo-colors", "path-clean", "pathdiff", "pretty_assertions", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 0d151a9000..c410e09a82 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,8 +26,11 @@ codex-exec = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } +codex-protocol-ts = { workspace = true } codex-tui = { workspace = true } +owo-colors = { workspace = true } serde_json = { workspace = true } +supports-color = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -37,7 +40,6 @@ tokio = { workspace = true, features = [ ] } tracing = { workspace = true } tracing-subscriber = { workspace = true } -codex-protocol-ts = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e66855fe73..df757b0cc5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,8 +14,11 @@ use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; +use owo_colors::OwoColorize; use std::path::PathBuf; +use supports_color::Stream; mod mcp_cmd; @@ -156,6 +159,41 @@ struct GenerateTsCommand { prettier: Option, } +fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { + let AppExitInfo { + token_usage, + conversation_id, + } = exit_info; + + if token_usage.is_zero() { + return Vec::new(); + } + + let mut lines = vec![format!( + "{}", + codex_core::protocol::FinalOutput::from(token_usage) + )]; + + if let Some(session_id) = conversation_id { + let resume_cmd = format!("codex resume {session_id}"); + let command = if color_enabled { + resume_cmd.cyan().to_string() + } else { + resume_cmd + }; + lines.push(format!("To continue this session, run {command}.")); + } + + lines +} + +fn print_exit_messages(exit_info: AppExitInfo) { + let color_enabled = supports_color::on(Stream::Stdout).is_some(); + for line in format_exit_messages(exit_info, color_enabled) { + println!("{line}"); + } +} + fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { cli_main(codex_linux_sandbox_exe).await?; @@ -176,13 +214,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut interactive.config_overrides, root_config_overrides.clone(), ); - let usage = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - if !usage.token_usage.is_zero() { - println!( - "{}", - codex_core::protocol::FinalOutput::from(usage.token_usage) - ); - } + let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + print_exit_messages(exit_info); } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags( @@ -372,6 +405,8 @@ fn print_completion(cmd: CompletionCommand) { #[cfg(test)] mod tests { use super::*; + use codex_core::protocol::TokenUsage; + use codex_protocol::mcp_protocol::ConversationId; fn finalize_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); @@ -393,6 +428,52 @@ mod tests { finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli) } + fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo { + let token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + AppExitInfo { + token_usage, + conversation_id: conversation + .map(ConversationId::from_string) + .map(Result::unwrap), + } + } + + #[test] + fn format_exit_messages_skips_zero_usage() { + let exit_info = AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + }; + let lines = format_exit_messages(exit_info, false); + assert!(lines.is_empty()); + } + + #[test] + fn format_exit_messages_includes_resume_hint_without_color() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, false); + assert_eq!( + lines, + vec![ + "Token usage: total=2 input=0 output=2".to_string(), + "To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000." + .to_string(), + ] + ); + } + + #[test] + fn format_exit_messages_applies_color_when_enabled() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, true); + assert_eq!(lines.len(), 2); + assert!(lines[1].contains("\u{1b}[36m")); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5-test"].as_ref()); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b029f72216..38546d0bc0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -85,7 +85,6 @@ unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } pathdiff = { workspace = true } -owo-colors = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index a9b426fe45..50ea95f170 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -3,8 +3,6 @@ use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; -use owo_colors::OwoColorize; -use supports_color::Stream; #[derive(Parser, Debug)] struct TopCli { @@ -25,19 +23,8 @@ fn main() -> anyhow::Result<()> { .splice(0..0, top_cli.config_overrides.raw_overrides); let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; let token_usage = exit_info.token_usage; - let conversation_id = exit_info.conversation_id; if !token_usage.is_zero() { println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); - if let Some(session_id) = conversation_id { - let command = format!("codex resume {session_id}"); - let prefix = "To continue this session, run "; - let suffix = "."; - if supports_color::on(Stream::Stdout).is_some() { - println!("{}{}{}", prefix, command.cyan(), suffix); - } else { - println!("{prefix}{command}{suffix}"); - } - } } Ok(()) }) From c415827ac20b7af1155fa7aaabdd0da70d4779d7 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Mon, 22 Sep 2025 16:12:26 -0700 Subject: [PATCH 41/42] Truncate potentially long user messages in compact message. (#4068) If a prior user message is massive, any future `/compact` task would fail because we're verbatim copying the user message into the new chat. --- codex-rs/core/src/codex/compact.rs | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 05d57cd945..d1547a4818 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -18,6 +18,7 @@ use crate::protocol::InputMessageKind; use crate::protocol::TaskCompleteEvent; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; +use crate::truncate::truncate_middle; use crate::util::backoff; use askama::Template; use codex_protocol::models::ContentItem; @@ -28,6 +29,7 @@ use futures::prelude::*; pub(super) const COMPACT_TRIGGER_TEXT: &str = "Start Summarization"; const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); +const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; #[derive(Template)] #[template(path = "compact/history_bridge.md", escape = "none")] @@ -248,11 +250,17 @@ pub(crate) fn build_compacted_history( summary_text: &str, ) -> Vec { let mut history = initial_context; - let user_messages_text = if user_messages.is_empty() { + let mut user_messages_text = if user_messages.is_empty() { "(none)".to_string() } else { user_messages.join("\n\n") }; + // Truncate the concatenated prior user messages so the bridge message + // stays well under the context window (approx. 4 bytes/token). + let max_bytes = COMPACT_USER_MESSAGE_MAX_TOKENS * 4; + if user_messages_text.len() > max_bytes { + user_messages_text = truncate_middle(&user_messages_text, max_bytes).0; + } let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { @@ -396,4 +404,38 @@ mod tests { assert_eq!(vec!["real user message".to_string()], collected); } + + #[test] + fn build_compacted_history_truncates_overlong_user_messages() { + // Prepare a very large prior user message so the aggregated + // `user_messages_text` exceeds the truncation threshold used by + // `build_compacted_history` (80k bytes). + let big = "X".repeat(200_000); + let history = build_compacted_history(Vec::new(), std::slice::from_ref(&big), "SUMMARY"); + + // Expect exactly one bridge message added to history (plus any initial context we provided, which is none). + assert_eq!(history.len(), 1); + + // Extract the text content of the bridge message. + let bridge_text = match &history[0] { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("unexpected item in history: {other:?}"), + }; + + // The bridge should contain the truncation marker and not the full original payload. + assert!( + bridge_text.contains("tokens truncated"), + "expected truncation marker in bridge message" + ); + assert!( + !bridge_text.contains(&big), + "bridge should not include the full oversized user text" + ); + assert!( + bridge_text.contains("SUMMARY"), + "bridge should include the provided summary text" + ); + } } From c93e77b68b49562a46e7dc47b3eb529942e078b0 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Mon, 22 Sep 2025 20:10:52 -0700 Subject: [PATCH 42/42] feat: update default (#4076) Changes: - Default model and docs now use gpt-5-codex. - Disables the GPT-5 Codex NUX by default. - Keeps presets available for API key users. --- codex-rs/common/src/model_presets.rs | 12 +-- codex-rs/core/src/config.rs | 10 +-- codex-rs/core/src/config_edit.rs | 26 +++--- codex-rs/core/src/internal_storage.rs | 17 +++- codex-rs/core/tests/suite/client.rs | 2 +- .../core/tests/suite/compact_resume_fork.rs | 10 +-- codex-rs/core/tests/suite/prompt_caching.rs | 2 +- codex-rs/docs/codex_mcp_interface.md | 3 +- codex-rs/mcp-server/tests/suite/config.rs | 4 +- .../tests/suite/set_default_model.rs | 2 +- codex-rs/protocol/src/mcp_protocol.rs | 4 +- ...et__tests__binary_size_ideal_response.snap | 82 ++++++++++++++++++- .../tui/tests/fixtures/binary-size-log.jsonl | 6 +- docs/config.md | 8 +- 14 files changed, 136 insertions(+), 52 deletions(-) diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 065bb1e340..8eb5beacda 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -1,4 +1,3 @@ -use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL; use codex_core::protocol_config_types::ReasoningEffort; use codex_protocol::mcp_protocol::AuthMode; @@ -69,13 +68,6 @@ const PRESETS: &[ModelPreset] = &[ }, ]; -pub fn builtin_model_presets(auth_mode: Option) -> Vec { - match auth_mode { - Some(AuthMode::ApiKey) => PRESETS - .iter() - .copied() - .filter(|p| p.model != GPT_5_CODEX_MEDIUM_MODEL) - .collect(), - _ => PRESETS.to_vec(), - } +pub fn builtin_model_presets(_auth_mode: Option) -> Vec { + PRESETS.to_vec() } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6d1f2ee170..508a3dc36f 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -37,7 +37,7 @@ use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; -const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; +const OPENAI_DEFAULT_MODEL: &str = "gpt-5-codex"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5-codex"; pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5-codex"; @@ -54,7 +54,7 @@ pub struct Config { /// Optional override of model selection. pub model: String, - /// Model used specifically for review sessions. Defaults to "gpt-5". + /// Model used specifically for review sessions. Defaults to "gpt-5-codex". pub review_model: String, pub model_family: ModelFamily, @@ -1366,7 +1366,7 @@ startup_timeout_ms = 2500 tokio::fs::write( &config_path, r#" -model = "gpt-5" +model = "gpt-5-codex" model_reasoning_effort = "medium" [profiles.dev] @@ -1441,7 +1441,7 @@ model = "gpt-4" model_reasoning_effort = "medium" [profiles.prod] -model = "gpt-5" +model = "gpt-5-codex" "#, ) .await?; @@ -1472,7 +1472,7 @@ model = "gpt-5" .profiles .get("prod") .and_then(|profile| profile.model.as_deref()), - Some("gpt-5"), + Some("gpt-5-codex"), ); Ok(()) diff --git a/codex-rs/core/src/config_edit.rs b/codex-rs/core/src/config_edit.rs index 7b1d75d08d..6e68c08ca0 100644 --- a/codex-rs/core/src/config_edit.rs +++ b/codex-rs/core/src/config_edit.rs @@ -228,7 +228,7 @@ mod tests { codex_home, None, &[ - (&[CONFIG_KEY_MODEL], "gpt-5"), + (&[CONFIG_KEY_MODEL], "gpt-5-codex"), (&[CONFIG_KEY_EFFORT], "high"), ], ) @@ -236,7 +236,7 @@ mod tests { .expect("persist"); let contents = read_config(codex_home).await; - let expected = r#"model = "gpt-5" + let expected = r#"model = "gpt-5-codex" model_reasoning_effort = "high" "#; assert_eq!(contents, expected); @@ -348,7 +348,7 @@ model_reasoning_effort = "high" &[ (&["a", "b", "c"], "v"), (&["x"], "y"), - (&["profiles", "p1", CONFIG_KEY_MODEL], "gpt-5"), + (&["profiles", "p1", CONFIG_KEY_MODEL], "gpt-5-codex"), ], ) .await @@ -361,7 +361,7 @@ model_reasoning_effort = "high" c = "v" [profiles.p1] -model = "gpt-5" +model = "gpt-5-codex" "#; assert_eq!(contents, expected); } @@ -454,7 +454,7 @@ existing = "keep" codex_home, None, &[ - (&[CONFIG_KEY_MODEL], "gpt-5"), + (&[CONFIG_KEY_MODEL], "gpt-5-codex"), (&[CONFIG_KEY_EFFORT], "minimal"), ], ) @@ -466,7 +466,7 @@ existing = "keep" # should be preserved existing = "keep" -model = "gpt-5" +model = "gpt-5-codex" model_reasoning_effort = "minimal" "#; assert_eq!(contents, expected); @@ -524,7 +524,7 @@ model = "o3" let codex_home = tmpdir.path(); // Seed with a model value only - let seed = "model = \"gpt-5\"\n"; + let seed = "model = \"gpt-5-codex\"\n"; tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed) .await .expect("seed write"); @@ -535,7 +535,7 @@ model = "o3" .expect("persist"); let contents = read_config(codex_home).await; - let expected = r#"model = "gpt-5" + let expected = r#"model = "gpt-5-codex" model_reasoning_effort = "high" "#; assert_eq!(contents, expected); @@ -579,7 +579,7 @@ model = "o4-mini" // No active profile key; we'll target an explicit override let seed = r#"[profiles.team] -model = "gpt-5" +model = "gpt-5-codex" "#; tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed) .await @@ -595,7 +595,7 @@ model = "gpt-5" let contents = read_config(codex_home).await; let expected = r#"[profiles.team] -model = "gpt-5" +model = "gpt-5-codex" model_reasoning_effort = "minimal" "#; assert_eq!(contents, expected); @@ -611,7 +611,7 @@ model_reasoning_effort = "minimal" codex_home, None, &[ - (&[CONFIG_KEY_MODEL], Some("gpt-5")), + (&[CONFIG_KEY_MODEL], Some("gpt-5-codex")), (&[CONFIG_KEY_EFFORT], None), ], ) @@ -619,7 +619,7 @@ model_reasoning_effort = "minimal" .expect("persist"); let contents = read_config(codex_home).await; - let expected = "model = \"gpt-5\"\n"; + let expected = "model = \"gpt-5-codex\"\n"; assert_eq!(contents, expected); } @@ -670,7 +670,7 @@ model = "o3" let tmpdir = tempdir().expect("tmp"); let codex_home = tmpdir.path(); - let seed = r#"model = "gpt-5" + let seed = r#"model = "gpt-5-codex" model_reasoning_effort = "medium" "#; tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed) diff --git a/codex-rs/core/src/internal_storage.rs b/codex-rs/core/src/internal_storage.rs index e8e0c09057..666d9287eb 100644 --- a/codex-rs/core/src/internal_storage.rs +++ b/codex-rs/core/src/internal_storage.rs @@ -7,14 +7,27 @@ use std::path::PathBuf; pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json"; -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct InternalStorage { #[serde(skip)] storage_path: PathBuf, - #[serde(default)] + #[serde(default = "default_gpt_5_codex_model_prompt_seen")] pub gpt_5_codex_model_prompt_seen: bool, } +const fn default_gpt_5_codex_model_prompt_seen() -> bool { + true +} + +impl Default for InternalStorage { + fn default() -> Self { + Self { + storage_path: PathBuf::new(), + gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(), + } + } +} + // TODO(jif) generalise all the file writers and build proper async channel inserters. impl InternalStorage { pub fn load(codex_home: &Path) -> Self { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index bc6886746f..d885faafee 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -822,7 +822,7 @@ async fn token_count_includes_rate_limits_snapshot() { "reasoning_output_tokens": 0, "total_tokens": 123 }, - // Default model is gpt-5 in tests → 272000 context window + // Default model is gpt-5-codex in tests → 272000 context window "model_context_window": 272000 }, "rate_limits": { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 1e752826bb..43d6192f5f 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -133,7 +133,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { .to_string(); let user_turn_1 = json!( { - "model": "gpt-5", + "model": "gpt-5-codex", "instructions": prompt, "input": [ { @@ -182,7 +182,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { }); let compact_1 = json!( { - "model": "gpt-5", + "model": "gpt-5-codex", "instructions": "You have exceeded the maximum number of tokens, please stop coding and instead write a short memento message for the next agent. Your note should: - Summarize what you finished and what still needs work. If there was a recent update_plan call, repeat its steps verbatim. - List outstanding TODOs with file paths / line numbers so they're easy to find. @@ -255,7 +255,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { }); let user_turn_2_after_compact = json!( { - "model": "gpt-5", + "model": "gpt-5-codex", "instructions": prompt, "input": [ { @@ -320,7 +320,7 @@ SUMMARY_ONLY_CONTEXT" }); let usert_turn_3_after_resume = json!( { - "model": "gpt-5", + "model": "gpt-5-codex", "instructions": prompt, "input": [ { @@ -405,7 +405,7 @@ SUMMARY_ONLY_CONTEXT" }); let user_turn_3_after_fork = json!( { - "model": "gpt-5", + "model": "gpt-5-codex", "instructions": prompt, "input": [ { diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index a69f57a201..6cfe6f4d78 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -184,6 +184,7 @@ async fn prompt_tools_are_consistent_across_requests() { let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let expected_instructions = config.model_family.base_instructions.clone(); let codex = conversation_manager .new_conversation(config) .await @@ -213,7 +214,6 @@ async fn prompt_tools_are_consistent_across_requests() { let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2, "expected two POST requests"); - let expected_instructions: &str = include_str!("../../prompt.md"); // our internal implementation is responsible for keeping tools in sync // with the OpenAI schema, so we just verify the tool presence here let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch", "view_image"]; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 1b048085c3..8f0c279058 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -51,7 +51,7 @@ Start a new session with optional overrides: Request `newConversation` params (subset): -- `model`: string model id (e.g. "o3", "gpt-5") +- `model`: string model id (e.g. "o3", "gpt-5", "gpt-5-codex") - `profile`: optional named profile - `cwd`: optional working directory - `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never` @@ -120,4 +120,3 @@ While processing, the server emits `codex/event` notifications containing agent ## Compatibility and stability This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`. - diff --git a/codex-rs/mcp-server/tests/suite/config.rs b/codex-rs/mcp-server/tests/suite/config.rs index bc8789ce78..da64648c49 100644 --- a/codex-rs/mcp-server/tests/suite/config.rs +++ b/codex-rs/mcp-server/tests/suite/config.rs @@ -26,7 +26,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { std::fs::write( config_toml, r#" -model = "gpt-5" +model = "gpt-5-codex" approval_policy = "on-request" sandbox_mode = "workspace-write" model_reasoning_summary = "detailed" @@ -92,7 +92,7 @@ async fn get_config_toml_parses_all_fields() { exclude_tmpdir_env_var: Some(true), exclude_slash_tmp: Some(true), }), - model: Some("gpt-5".into()), + model: Some("gpt-5-codex".into()), model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: Some(ReasoningSummary::Detailed), model_verbosity: Some(Verbosity::Medium), diff --git a/codex-rs/mcp-server/tests/suite/set_default_model.rs b/codex-rs/mcp-server/tests/suite/set_default_model.rs index 7ffee3438d..f7e1041fa7 100644 --- a/codex-rs/mcp-server/tests/suite/set_default_model.rs +++ b/codex-rs/mcp-server/tests/suite/set_default_model.rs @@ -69,7 +69,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { std::fs::write( config_toml, r#" -model = "gpt-5" +model = "gpt-5-codex" model_reasoning_effort = "medium" "#, ) diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index c9137d0f94..52418b2755 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -710,7 +710,7 @@ mod tests { let request = ClientRequest::NewConversation { request_id: RequestId::Integer(42), params: NewConversationParams { - model: Some("gpt-5".to_string()), + model: Some("gpt-5-codex".to_string()), profile: None, cwd: None, approval_policy: Some(AskForApproval::OnRequest), @@ -726,7 +726,7 @@ mod tests { "method": "newConversation", "id": 42, "params": { - "model": "gpt-5", + "model": "gpt-5-codex", "approvalPolicy": "on-request" } }), 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 index 0b6ec3b392..caabd816bb 100644 --- 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 @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 648 expression: visible_after --- > I’m going to scan the workspace and Cargo manifests to see build profiles and @@ -10,6 +9,14 @@ expression: visible_after └ List ls -la Read Cargo.toml +• I'm reviewing the workspace's release profile, which has settings like + lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it + seems that "strip = symbols" only removes symbols, not debug information. In + Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I + should check whether using these results in a smaller binary. I’ll also skim + through crates for heavy dependencies in Cargo.toml, including cli, core, + exec, linux-sandbox, tui, login, ollama, and mcp. + • 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 @@ -19,6 +26,79 @@ expression: visible_after • Explored └ Read Cargo.toml +• I’m evaluating how binary size is influenced by various factors. There are + many binaries, such as codex and codex-exec, which include dependencies by + default, pulling in features like Tokio and hyper. For instance, reqwest + includes default features like TLS through rustls. If default features remain + enabled, it inflates the size with many additional components. I notice + that for musl targets, using vendored builds of OpenSSL could significantly + increase binary size. This is quite the intricate web of dependencies! + + Analyzing musl and binary size + + I’m considering the implications of building for musl with static linking. + In this case, OpenSSL might come into play through some crates, but reqwest + defaults to using rustls, although openssl-sys vendored could increase + binary size. It’s interesting to think about whether they’re targeting musl, + especially since it seems they might often be building for Mac and Linux with + dynamic linking instead. + + I also realize there’s a difference between debug and release builds — release + should be smaller due to optimizations, but if they’re defaulting to debug + builds, that could lead to much larger binaries. + + Assessing crate impacts on size + + I need to be precise when analyzing binary sizes from various crates. Some + crates, like ratatui-image, include heavy features such as libjpeg decoders, + which can really bulk things up. Then there are the tree-sitter crates that + contain large static grammar tables, adding significant size. The starlark + crate and tracing-subscriber also contribute to size increases. + + With static linking per binary being standard in Rust, it leads to multiple + copies of the same dependencies unless cdylibs are used. That makes the + compile times longer, but using LTO helps reduce bloat somewhat through + deduplication. + + Evaluating binary size factors + + Having multiple binaries indeed increases the overall size significantly. + I'm considering options like upgrading to "opt-level = 'z'" or using + "strip=debuginfo" for reducing size. It's interesting that while the profile + only sets symbols for stripping, not debuginfo, this could inflate sizes if + retained in release builds. + + The distinction between "symbols" and "debuginfo" is critical; "symbols" + keeps debug information which may not align with the goal of minimizing + size. I’d like to verify with the Rust documentation, but my network access + is restricted. + + Explaining large binary sizes + + I’m analyzing why binaries in this project are so large. The primary reasons + include monolithic static linking, where each binary includes the full + dependency graph without shared libraries. Heavy dependencies such as reqwest, + hyper, and TLS components also contribute significantly to size, along with + image decoders and other libraries. + + Using MUSL targets means vendored OpenSSL can drastically increase the + size due to static linking. Furthermore, if the release profile keeps debug + information due to using "strip = symbols," that can lead to larger binaries + as well. It’s a mix of several factors, including panic strategies and + platform-specific linking behavior. + + Identifying binary size contributors + + I’m considering why binaries are still large even without static linking. The + tracing-subscriber crate potentially adds weight because it includes regex, + though it can sometimes use a lighter version. References from files like + "core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses + the image crate. + + To present this clearly, I’ll format the final answer with two main sections: + "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for + reducing size, but I want to stay focused on answering the user's question. + > Here’s what’s driving size in this workspace’s binaries. Main Causes diff --git a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl index 2b243d18ec..392cf55250 100644 --- a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl +++ b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl @@ -1,4 +1,4 @@ -{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5","reasoning_effort":"medium","model_provider_id":"openai","model_provider_name":"OpenAI"} +{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5-codex","reasoning_effort":"medium","model_provider_id":"openai","model_provider_name":"OpenAI"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('m'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} @@ -34,7 +34,7 @@ {"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} {"ts":"2025-08-09T15:51:04.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} -{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} +{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5-codex","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} {"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -16447,7 +16447,7 @@ {"ts":"2025-08-09T16:06:58.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} -{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} +{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5-codex","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} diff --git a/docs/config.md b/docs/config.md index 5869203d57..ba204ee00a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -21,7 +21,7 @@ Both the `--config` flag and the `config.toml` file support the following option The model that Codex should use. ```toml -model = "o3" # overrides the default of "gpt-5" +model = "o3" # overrides the default of "gpt-5-codex" ``` ## model_providers @@ -223,11 +223,11 @@ Users can specify config values at multiple levels. Order of precedence is as fo 1. custom command-line argument, e.g., `--model o3` 2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) 3. as an entry in `config.toml`, e.g., `model = "o3"` -4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5`) +4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`) ## model_reasoning_effort -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - `"minimal"` - `"low"` @@ -606,7 +606,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | Key | Type / Values | Notes | | --- | --- | --- | -| `model` | string | Model to use (e.g., `gpt-5`). | +| `model` | string | Model to use (e.g., `gpt-5-codex`). | | `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. |