mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
Merge ccedbba696 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<String, CargoBinError> {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -110,6 +110,9 @@ pub(crate) struct ChatComposer {
|
||||
attached_images: Vec<AttachedImage>,
|
||||
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<String>,
|
||||
// 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<String>) {
|
||||
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::<AppEvent>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -113,6 +113,9 @@ pub(crate) struct ChatComposer {
|
||||
attached_images: Vec<AttachedImage>,
|
||||
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<String>,
|
||||
// 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<String>) {
|
||||
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::<AppEvent>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user