diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 5060092290..b0b98f04b0 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -126,7 +126,6 @@ codex-core = { path = ".", features = ["deterministic_process_ids"] } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } -escargot = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } maplit = { workspace = true } predicates = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 45e8b0b46f..7c5e5ca173 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -1,5 +1,6 @@ #![expect(clippy::expect_used)] +use codex_utils_cargo_bin::CargoBinError; use tempfile::TempDir; use codex_core::CodexThread; @@ -235,6 +236,10 @@ pub fn format_with_current_shell_display_non_login(command: &str) -> String { .expect("serialize current shell command without login") } +pub fn stdio_server_bin() -> Result { + codex_utils_cargo_bin::cargo_bin("test_stdio_server").map(|p| p.to_string_lossy().to_string()) +} + pub mod fs_wait { use anyhow::Result; use anyhow::anyhow; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 617b3b8a21..9274369967 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -19,12 +19,13 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::user_input::UserInput; +use codex_utils_cargo_bin::cargo_bin; use core_test_support::responses; use core_test_support::responses::mount_sse_once; use core_test_support::skip_if_no_network; +use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use escargot::CargoBuild; use mcp_types::ContentBlock; use serde_json::Value; use serde_json::json; @@ -68,13 +69,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { .await; let expected_env_value = "propagated-env"; - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -82,7 +77,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin.clone(), + command: rmcp_test_server_bin, args: Vec::new(), env: Some(HashMap::from([( "MCP_TEST_VALUE".to_string(), @@ -205,13 +200,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { .await; // Build the stdio rmcp server and pass the image as data URL so it can construct ImageContent. - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -399,13 +388,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { .mount(&server) .await; - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -546,13 +529,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { let expected_env_value = "propagated-env-from-whitelist"; let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value)); - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -680,13 +657,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { .await; let expected_env_value = "propagated-env-http"; - let rmcp_http_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_streamable_http_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_http_server_bin = cargo_bin("test_streamable_http_server")?; let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); @@ -848,13 +819,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let expected_token = "initial-access-token"; let client_id = "test-client-id"; let refresh_token = "initial-refresh-token"; - let rmcp_http_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_streamable_http_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_http_server_bin = cargo_bin("test_streamable_http_server")?; let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4b916e51b4..0176f8cde3 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -22,9 +22,9 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use escargot::CargoBuild; use serde_json::Value; use serde_json::json; use std::collections::HashMap; @@ -411,13 +411,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> .await; // Compile the rmcp stdio test server and configure it. - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_config(move |config| { config.mcp_servers.insert( @@ -497,13 +491,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { .await; // Build the stdio rmcp server and pass a tiny PNG via data URL so it can construct ImageContent. - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; @@ -762,13 +750,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { ) .await; - let rmcp_test_server_bin = CargoBuild::new() - .package("codex-rmcp-client") - .bin("test_stdio_server") - .run()? - .path() - .to_string_lossy() - .into_owned(); + let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_config(move |config| { config.tool_output_token_limit = Some(50_000); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6d47739c1b..f0ac9ca47f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -110,6 +110,9 @@ pub(crate) struct ChatComposer { attached_images: Vec, placeholder_text: String, is_task_running: bool, + /// When false, the composer is temporarily read-only (e.g. during sandbox setup). + input_enabled: bool, + input_disabled_placeholder: Option, // Non-bracketed paste burst tracker. paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. @@ -160,6 +163,8 @@ impl ChatComposer { attached_images: Vec::new(), placeholder_text, is_task_running: false, + input_enabled: true, + input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), @@ -488,6 +493,10 @@ impl ChatComposer { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if !self.input_enabled { + return (InputResult::None, false); + } + let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), @@ -1877,6 +1886,17 @@ impl ChatComposer { self.has_focus = has_focus; } + #[allow(dead_code)] + pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { + self.input_enabled = enabled; + self.input_disabled_placeholder = if enabled { None } else { placeholder }; + + // Avoid leaving interactive popups open while input is blocked. + if !enabled && !matches!(self.active_popup, ActivePopup::None) { + self.active_popup = ActivePopup::None; + } + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -1902,6 +1922,10 @@ impl ChatComposer { impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -1980,10 +2004,15 @@ impl Renderable for ChatComposer { let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); if !textarea_rect.is_empty() { + let prompt = if self.input_enabled { + "›".bold() + } else { + "›".dim() + }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, textarea_rect.y, - &"›".bold(), + &prompt, textarea_rect.width, ); } @@ -1991,7 +2020,15 @@ impl Renderable for ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { - let placeholder = Span::from(self.placeholder_text.as_str()).dim(); + let text = if self.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + let placeholder = Span::from(text).dim().italic(); Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); } } @@ -4389,4 +4426,38 @@ mod tests { ); assert_eq!(composer.attached_images.len(), 1); } + #[test] + fn input_disabled_ignores_keypresses_and_hides_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("hello".to_string()); + composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(!needs_redraw); + assert_eq!(composer.current_text(), "hello"); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 329d8ec805..fe626537ac 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -264,6 +264,16 @@ impl BottomPane { self.request_redraw(); } + #[allow(dead_code)] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw(); diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index e136b81971..daa861f58b 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -113,6 +113,9 @@ pub(crate) struct ChatComposer { attached_images: Vec, placeholder_text: String, is_task_running: bool, + /// When false, the composer is temporarily read-only (e.g. during sandbox setup). + input_enabled: bool, + input_disabled_placeholder: Option, // Non-bracketed paste burst tracker. paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. @@ -168,6 +171,8 @@ impl ChatComposer { attached_images: Vec::new(), placeholder_text, is_task_running: false, + input_enabled: true, + input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), @@ -405,6 +410,10 @@ impl ChatComposer { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if !self.input_enabled { + return (InputResult::None, false); + } + let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), @@ -1819,6 +1828,17 @@ impl ChatComposer { self.has_focus = has_focus; } + #[allow(dead_code)] + pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { + self.input_enabled = enabled; + self.input_disabled_placeholder = if enabled { None } else { placeholder }; + + // Avoid leaving interactive popups open while input is blocked. + if !enabled && !matches!(self.active_popup, ActivePopup::None) { + self.active_popup = ActivePopup::None; + } + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -1844,6 +1864,10 @@ impl ChatComposer { impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -1922,10 +1946,15 @@ impl Renderable for ChatComposer { let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); if !textarea_rect.is_empty() { + let prompt = if self.input_enabled { + "›".bold() + } else { + "›".dim() + }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, textarea_rect.y, - &"›".bold(), + &prompt, textarea_rect.width, ); } @@ -1933,7 +1962,15 @@ impl Renderable for ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { - let placeholder = Span::from(self.placeholder_text.as_str()).dim(); + let text = if self.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + let placeholder = Span::from(text).dim().italic(); Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); } } @@ -4108,4 +4145,38 @@ mod tests { "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" ); } + #[test] + fn input_disabled_ignores_keypresses_and_hides_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("hello".to_string()); + composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(!needs_redraw); + assert_eq!(composer.current_text(), "hello"); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + } } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 2ebd0715e7..4b6caf0d1a 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -256,6 +256,16 @@ impl BottomPane { self.request_redraw(); } + #[allow(dead_code)] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw();